You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Person has no explicit-id schema section today — it's discovered only implicitly via a Quote's author string field, matched by natural key (name) via ResolvePersonAsync, and is Add-only (EnsurePersonExistsAsync is a plain insert-if-not-exists). Person is already a global entity (no SourceId — unlike Character, one "Winston Churchill" row is already shared across every quote that references him), which makes it the simplest of the remaining entities to give explicit-id/Modify support to: no linkage/scoping design question, unlike Character. This issue also wires up Person.DateOfBirth/DateOfDeath — both columns already exist on the entity (predating this milestone) but nothing anywhere sets them; the Modify plumbing this issue adds makes populating them nearly free, and leaving them permanently dead is its own latent gap otherwise.
This issue's "global entity, Name-keyed" Modify shape is deliberately meant to be the direct template for Character's own explicit-id/Modify issue, once Character's separate per-Source-to-global identity migration (see the linked follow-on issue) lands.
What needs to be done
schemas/source-extended.schema.json gains a people array section + person$def: id (required, UUID v4 pattern, same regex as source/stageDirection/etc.), name (required), dateOfBirth (optional, imprecise ISO 8601 string, same convention as SourceEntry.Date), dateOfDeath (optional, same convention). Purely additive — a file without a people section parses identically to today.
New PersonEntry.cs record in Quotinator.Core.Import, doc-commented like SourceEntry. ParsedSourceFile gains People (defaults []). SourceQuoteFileReader.TryParseExtended gains the new root-key parse.
New PlanPeopleAsync (src/Quotinator.Engine/Database/ImportActionPlanner.cs), run before the quote loop, mirroring PlanSourcesAsync's shape: id-match lookup → field-map diff (name, dateOfBirth, dateOfDeath) → unchanged-check (silent reuse) → policy-based resolution → CompletenessGuard.ShouldBlock evaluated against the policy-resolved value → stage Blocked or Modify. Falls back to the existing natural-key lookup (Sql.People.SelectIdByName) when no id match — a not-yet-declared-by-id row found this way stays Add-only/natural-key-matched, same scope boundary as Source's own natural-key fallback. A person discovered only implicitly through a Quote's author string (no explicit people[] entry) stays Add-only forever, same rule.
ApplyResolvedActionAsync's Person case splits on ActionType: Add unchanged; Modify calls the new Sql.People.UpdateFieldsById against MergedFields — the first write path that ever populates DateOfBirth/DateOfDeath.
DecideAsync gains an EntityType == Person && ActionType == Modify branch, mirroring Source's branch shape.
ComputeAmbiguousFields gains a Person case.
ReverseAppliedActionsAsync's Person case splits on ActionType: Add keeps today's soft-delete-if-unreferenced; Modify restores Name/DateOfBirth/DateOfDeath via UpdateFieldsById from ExistingValue.
ClearStaleAddTargetsAsync's Person cleanup branch currently uses the Guid-typed repository path (_personRepository.HardDeleteAsync(Guid.Parse(...))), correct today only because every Person id is EntityIdentity-derived (always uppercase). This issue must switch it to the raw-SQL, case-preserving pattern (RepositorySql.HardDelete("People")), the same fix Source: explicit file-carried id, decoupling matching from Title/Type/Date content #162 made for Source — an explicit people[] id is file-authored and not guaranteed uppercase. Non-optional part of this issue's scope, not a nice-to-have.
Background
Person has no explicit-id schema section today — it's discovered only implicitly via a Quote's
authorstring field, matched by natural key (name) viaResolvePersonAsync, and is Add-only (EnsurePersonExistsAsyncis a plain insert-if-not-exists). Person is already a global entity (noSourceId— unlike Character, one "Winston Churchill" row is already shared across every quote that references him), which makes it the simplest of the remaining entities to give explicit-id/Modify support to: no linkage/scoping design question, unlike Character. This issue also wires upPerson.DateOfBirth/DateOfDeath— both columns already exist on the entity (predating this milestone) but nothing anywhere sets them; the Modify plumbing this issue adds makes populating them nearly free, and leaving them permanently dead is its own latent gap otherwise.This issue's "global entity, Name-keyed" Modify shape is deliberately meant to be the direct template for Character's own explicit-id/Modify issue, once Character's separate per-Source-to-global identity migration (see the linked follow-on issue) lands.
What needs to be done
schemas/source-extended.schema.jsongains apeoplearray section +person$def:id(required, UUID v4 pattern, same regex assource/stageDirection/etc.),name(required),dateOfBirth(optional, imprecise ISO 8601 string, same convention asSourceEntry.Date),dateOfDeath(optional, same convention). Purely additive — a file without apeoplesection parses identically to today.PersonEntry.csrecord inQuotinator.Core.Import, doc-commented likeSourceEntry.ParsedSourceFilegainsPeople(defaults[]).SourceQuoteFileReader.TryParseExtendedgains the new root-key parse.Sql.People(src/Quotinator.Engine/Queries/Sql.cs) gainsSelectExistingById(returnsName,DateOfBirth,DateOfDeath,CompletenessStatus),UpdateFieldsById,SelectCompletenessById,UpdateCompletenessById.PlanPeopleAsync(src/Quotinator.Engine/Database/ImportActionPlanner.cs), run before the quote loop, mirroringPlanSourcesAsync's shape: id-match lookup → field-map diff (name,dateOfBirth,dateOfDeath) → unchanged-check (silent reuse) → policy-based resolution →CompletenessGuard.ShouldBlockevaluated against the policy-resolved value → stageBlockedorModify. Falls back to the existing natural-key lookup (Sql.People.SelectIdByName) when no id match — a not-yet-declared-by-id row found this way stays Add-only/natural-key-matched, same scope boundary as Source's own natural-key fallback. A person discovered only implicitly through a Quote'sauthorstring (no explicitpeople[]entry) stays Add-only forever, same rule.ApplyResolvedActionAsync's Person case splits onActionType:Addunchanged;Modifycalls the newSql.People.UpdateFieldsByIdagainstMergedFields— the first write path that ever populatesDateOfBirth/DateOfDeath.DecideAsyncgains anEntityType == Person && ActionType == Modifybranch, mirroring Source's branch shape.ComputeAmbiguousFieldsgains aPersoncase.ReverseAppliedActionsAsync's Person case splits onActionType:Addkeeps today's soft-delete-if-unreferenced;ModifyrestoresName/DateOfBirth/DateOfDeathviaUpdateFieldsByIdfromExistingValue.ClearStaleAddTargetsAsync's Person cleanup branch currently uses the Guid-typed repository path (_personRepository.HardDeleteAsync(Guid.Parse(...))), correct today only because every Person id isEntityIdentity-derived (always uppercase). This issue must switch it to the raw-SQL, case-preserving pattern (RepositorySql.HardDelete("People")), the same fix Source: explicit file-carried id, decoupling matching from Title/Type/Date content #162 made for Source — an explicitpeople[]id is file-authored and not guaranteed uppercase. Non-optional part of this issue's scope, not a nice-to-have.ConflictDecisionRequestgainsPersonName,PersonDateOfBirth,PersonDateOfDeath(nullableFieldDecision?).Expected tests
Quotinator.Core.TestsSourceQuoteFileReader_PeopleSection_ParsesCorrectlyQuotinator.Engine.TestsPlanPeopleAsync_IdMatchFound_NameDiffers_StagesModifyActionQuotinator.Engine.TestsPlanPeopleAsync_IdMatchFound_NothingChanged_NoActionStagedQuotinator.Engine.TestsPlanPeopleAsync_NoIdMatch_FallsBackToNaturalKey_NoActionStagedQuotinator.Engine.TestsPlanPeopleAsync_CompleteStatus_StagesBlockedNotModifyQuotinator.Engine.TestsPlanPeopleAsync_CompleteStatus_SkipPolicy_DoesNotBlockQuotinator.Engine.TestsApplyBatchAsync_PersonModify_WritesDateOfBirthAndDateOfDeathQuotinator.Engine.TestsDecideAsync_PersonModify_ResolvesFieldDecisionsQuotinator.Engine.TestsReverseBatchAsync_PersonModify_RestoresExistingValueQuotinator.Engine.TestsClearStaleAddTargetsAsync_PersonExplicitLowercaseId_HardDeletesCorrectlyDefinition of done