Skip to content

Implement contact-card YAML front matter parser/serializer#588

Merged
Chris0Jeky merged 8 commits intomainfrom
feature/264-contact-card-yaml-parser
Mar 30, 2026
Merged

Implement contact-card YAML front matter parser/serializer#588
Chris0Jeky merged 8 commits intomainfrom
feature/264-contact-card-yaml-parser

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Summary

  • Adds ContactCardFrontMatter DTO modeling structured contact fields (name, handles, tags, status, cadence, etc.) stored as YAML front matter in card descriptions for the card-first Outreach CRM (issue OUT-02: Implement contact-card YAML front matter parser/serializer contract #264, Wave 2 foundation)
  • Adds ContactCardYamlParser static utility with Parse and Serialize methods: extracts ----delimited YAML front matter, deserializes via YamlDotNet with underscore naming convention, validates type/tier/status enums, and returns explicit error lists instead of throwing
  • Round-trip safety: Parse(Serialize(fm, body)) produces identical output
  • Graceful degradation: malformed YAML, missing closing delimiter, invalid field values all return structured errors without crashing
  • 38 unit tests covering round-trip stability, edge cases (null/empty input, no front matter, Windows line endings, Unicode, special characters, unknown fields, trailing whitespace), validation, and serializer behavior

Closes #264

Test plan

  • All 38 new ContactCardYamlParserTests pass
  • Full backend test suite passes (1,012 Application + 407 Api + 218 Domain + 8 Architecture + 4 CLI = all green)
  • Round-trip test verifies Serialize -> Parse -> Serialize stability
  • Edge cases: null, empty, plain text, no delimiters, unclosed delimiter, empty YAML block, invalid YAML syntax
  • Validation: invalid type, invalid relationship_tier, invalid status, multiple simultaneous errors
  • Encoding: Windows line endings (\r\n), Unicode display names, special characters in notes
  • Serializer: null field omission, underscore naming convention, ArgumentNullException on null input

Defines structured contact fields (name, email, handles, tags, status,
cadence, etc.) that are stored as YAML front matter in card descriptions.
Static utility that extracts YAML front matter (delimited by ---) from
card descriptions into ContactCardFrontMatter, serializes back with
round-trip stability, and returns explicit validation errors for
malformed input rather than throwing.
Covers round-trip stability, no front matter, empty/null input,
malformed YAML, missing closing delimiter, validation errors
(type, tier, status), Windows line endings, Unicode, special
characters, unknown fields, trailing whitespace on delimiters,
and serializer null-omission and naming conventions.
@chatgpt-codex-connector
Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a YAML front matter parser and serializer for contact cards, enabling structured metadata storage within card descriptions. It includes the ContactCardFrontMatter DTO, the ContactCardYamlParser service, and a comprehensive suite of unit tests. The feedback focuses on improving performance by caching static resources like serializers and validation sets, enhancing data integrity through date format validation, and simplifying test assertions using object equivalency.

errors.Add($"Invalid status '{fm.Status}'. Expected one of: cold, warm, active, referral, interviewing, closed.");
}

return errors;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The Validate method is missing validation for LastTouchAt and NextTouchAt properties. This could allow invalid date formats to be saved, potentially causing parsing errors later. Please add validation to ensure these fields, if present, are valid ISO 8601 date strings (e.g., "YYYY-MM-DD").

You can use DateOnly.TryParse() for this before returning the errors. For example:

if (!string.IsNullOrEmpty(fm.LastTouchAt) && !DateOnly.TryParse(fm.LastTouchAt, out _))
{
    errors.Add($"Invalid last_touch_at format '{fm.LastTouchAt}'. Expected ISO 8601 date (YYYY-MM-DD).");
}
// and similarly for NextTouchAt

Comment on lines +119 to +132
var validTiers = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "A", "B", "C" };
if (!string.IsNullOrEmpty(fm.RelationshipTier) && !validTiers.Contains(fm.RelationshipTier))
{
errors.Add($"Invalid relationship_tier '{fm.RelationshipTier}'. Expected one of: A, B, C.");
}

var validStatuses = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"cold", "warm", "active", "referral", "interviewing", "closed"
};
if (!string.IsNullOrEmpty(fm.Status) && !validStatuses.Contains(fm.Status))
{
errors.Add($"Invalid status '{fm.Status}'. Expected one of: cold, warm, active, referral, interviewing, closed.");
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The validTiers and validStatuses HashSets are re-created on every call to Validate. For better performance and to adhere to best practices, these should be defined once as static readonly fields within the ContactCardYamlParser class.

You can add these fields to ContactCardYamlParser:

private static readonly HashSet<string> ValidTiers = new(StringComparer.OrdinalIgnoreCase) { "A", "B", "C" };
private static readonly HashSet<string> ValidStatuses = new(StringComparer.OrdinalIgnoreCase)
{
    "cold", "warm", "active", "referral", "interviewing", "closed"
};

Then, the validation logic can be simplified as suggested.

        if (!string.IsNullOrEmpty(fm.RelationshipTier) && !ValidTiers.Contains(fm.RelationshipTier))
        {
            errors.Add($"Invalid relationship_tier '{fm.RelationshipTier}'. Expected one of: A, B, C.");
        }

        if (!string.IsNullOrEmpty(fm.Status) && !ValidStatuses.Contains(fm.Status))
        {
            errors.Add($"Invalid status '{fm.Status}'. Expected one of: cold, warm, active, referral, interviewing, closed.");
        }

Comment on lines +209 to +224
private static IDeserializer BuildDeserializer()
{
return new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
}

private static ISerializer BuildSerializer()
{
return new SerializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
.Build();
}
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The IDeserializer and ISerializer instances are built on every call to Parse and Serialize respectively. Since their configuration is constant, they can be created once and stored in static readonly fields for improved performance.

You can define them at the class level:

private static readonly IDeserializer Deserializer = new DeserializerBuilder()
    .WithNamingConvention(UnderscoredNamingConvention.Instance)
    .IgnoreUnmatchedProperties()
    .Build();

private static readonly ISerializer Serializer = new SerializerBuilder()
    .WithNamingConvention(UnderscoredNamingConvention.Instance)
    .ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
    .Build();

Then, you can use these fields directly in Parse and Serialize and remove the BuildDeserializer() and BuildSerializer() methods.

Comment on lines +42 to +58
result.Errors.Should().BeEmpty();
result.FrontMatter.Should().NotBeNull();
result.FrontMatter!.Type.Should().Be("contact");
result.FrontMatter.DisplayName.Should().Be("Jane Doe");
result.FrontMatter.RelationshipTier.Should().Be("A");
result.FrontMatter.Company.Should().Be("Google");
result.FrontMatter.Role.Should().Be("SRE");
result.FrontMatter.LocationTz.Should().Be("Europe/London");
result.FrontMatter.Handles.Should().ContainKey("email").WhoseValue.Should().Be("jane@example.com");
result.FrontMatter.Tags.Should().BeEquivalentTo(new[] { "google", "platform", "referral-target" });
result.FrontMatter.Source.Should().Be("GE colleague");
result.FrontMatter.Status.Should().Be("warm");
result.FrontMatter.CadenceId.Should().Be("warm-3-7-21");
result.FrontMatter.LastTouchAt.Should().Be("2026-02-20");
result.FrontMatter.NextTouchAt.Should().Be("2026-02-27");
result.FrontMatter.NotesPrivate.Should().Be("Met at X; cares about reliability; likes concise messages.");
result.Body.Should().Be(body);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Instead of asserting each property individually, you can use FluentAssertions' BeEquivalentTo to compare the deserialized FrontMatter object with the original one. This makes the test more concise and easier to maintain.

        result.Errors.Should().BeEmpty();
        result.FrontMatter.Should().BeEquivalentTo(original);
        result.Body.Should().Be(body);

Comment on lines +76 to +80
result.Errors.Should().BeEmpty();
result.FrontMatter.Should().NotBeNull();
result.FrontMatter!.DisplayName.Should().Be("Minimal User");
result.FrontMatter.Type.Should().Be("contact");
result.Body.Should().BeEmpty();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the full contact round-trip test, you can use BeEquivalentTo to simplify the assertions here. This makes the test more concise and robust.

        result.Errors.Should().BeEmpty();
        result.FrontMatter.Should().BeEquivalentTo(original);
        result.Body.Should().BeEmpty();

Avoids rebuilding YamlDotNet builders and HashSet allocations on every
Parse/Serialize/Validate call. Both ISerializer and IDeserializer are
thread-safe after construction.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Adversarial Self-Review

YAML injection / security

  • No risk: YamlDotNet does not support arbitrary code execution tags (unlike Python's PyYAML with !!python/exec). The deserializer uses IgnoreUnmatchedProperties() so unexpected fields are silently dropped without side effects.
  • Validation error messages include user-supplied values (e.g. Invalid status 'X'). These are safe for internal use but callers should be aware when surfacing to external UI.

Encoding

  • Line ending normalization: ExtractFrontMatterBlock normalizes \r\n and \r to \n. This means the body is returned with \n endings even if the input had \r\n. The Serializer also uses \n. This is intentional and consistent: round-trip Serialize -> Parse -> Serialize is stable, but parsing externally-authored content with \r\n in the body will normalize those endings. Acceptable for the card-first use case.

Round-trip safety

  • Verified: Serialize(fm, body) -> Parse -> Serialize produces identical output (tested in RoundTrip_FullContact_ProducesIdenticalOutput).
  • Field ordering: YamlDotNet serializes in property-declaration order, so round-trip is deterministic. However, if a user hand-edits YAML with a different field order, parsing succeeds but re-serialization will reorder fields. This is expected and documented by convention.

Edge cases covered

  • Null/empty input, plain text (no delimiters), delimiters not at start, unclosed delimiter, empty YAML block, invalid YAML syntax, unknown fields, trailing whitespace on delimiters, multiple --- lines in body, no body after closing delimiter, Windows line endings, Unicode, special characters, quoted values, empty collections.

Issues found and fixed

  1. Allocation waste (fixed in ba73235): BuildDeserializer() and BuildSerializer() created new YamlDotNet builder instances on every call. Validation HashSets were also reallocated per call. Refactored to static readonly fields. YamlDotNet's ISerializer/IDeserializer are thread-safe after construction.

Remaining notes (not bugs)

  • ContactCardFrontMatter.Type defaults to "contact" (non-null), so it is always serialized. An empty-string Type would pass validation (only non-empty non-"contact" values are flagged) but serialize as type: "". This is edge-case-only and harmless.
  • No size limit on the YAML block or body. For card descriptions this is fine, but if this parser is ever used on untrusted external input, a max-size guard should be added.

…ch fields

- Resolve conflicts with main
- Add ISO 8601 date validation for last_touch_at and next_touch_at fields (Gemini HIGH)
- Add tests for date format validation
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Fixes applied

Merge conflicts resolved — rebased/merged with current main.

Gemini HIGH — Date validation added (ContactCardYamlParser.Validate):

  • last_touch_at and next_touch_at now validated as ISO 8601 date strings (YYYY-MM-DD) using DateOnly.TryParse()
  • Invalid dates produce structured errors, consistent with existing type/tier/status validation
  • 3 new test cases cover: invalid last_touch_at, invalid next_touch_at, valid date passes

Gemini MEDIUMs — Static caching (from self-review commit ba73235): already applied — IDeserializer, ISerializer, ValidTiers, ValidStatuses are all private static readonly.

Gemini MEDIUMs — Test simplification with BeEquivalentTo: keeping current test style for now as the assertions are explicit and easy to read; no functional gap.

…ep YamlDotNet

Keep Microsoft.IdentityModel.Tokens and System.IdentityModel.Tokens.Jwt at 8.17.0
from main (security upgrade) in both Application and Infrastructure projects, while
retaining YamlDotNet 16.3.0 added by this PR.
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Merge conflict resolved

Taskdeck.Application.csproj and Taskdeck.Infrastructure.csproj: Resolved package reference conflict by combining both sides in both projects:

  • Microsoft.IdentityModel.Tokens8.17.0 (from main — security upgrade)
  • System.IdentityModel.Tokens.Jwt8.17.0 (from main — security upgrade)
  • YamlDotNet16.3.0 (kept from PR — required for YAML parser)

The prior "Resolve merge conflicts" commit had left both projects at 7.7.1, causing a NU1605 version downgrade error. Both .csproj files now use 8.17.0 consistently.

Build: succeeded (0 warnings, 0 errors). ContactCardYamlParser tests: 41 passed.

@Chris0Jeky Chris0Jeky merged commit f86e298 into main Mar 30, 2026
18 checks passed
@Chris0Jeky Chris0Jeky deleted the feature/264-contact-card-yaml-parser branch March 30, 2026 23:09
@github-project-automation github-project-automation bot moved this from Pending to Done in Taskdeck Execution Mar 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

OUT-02: Implement contact-card YAML front matter parser/serializer contract

1 participant