Person archive propagation: person.updated v1 is registered, the PATCH route ships, asking Sales and Growth for archive-aware consumer commitments
Why
Two gaps surfaced in operator testing this week. First, there is no path to take a test-mode Person off the active surface in either domain without going to the database directly. Second, the identity contract names the right propagation shape for archive semantics (status transition emits person.updated; consumers filter by status; archived means "preserve history, do not accept new relationships, stop comms") but the implementation gap meant the event was not registered and the PATCH route did not exist.
Platform has now closed both gaps on the Platform side. This memo notifies Sales and Growth and asks each domain to commit to the consumer half so the propagation actually completes, not just the producer.
What
Three pieces landed in the Platform repo on the date of this memo.
person.updated v1 is registered
The event-type registry at contracts/event-types-registry.json carries a new entry for person.updated with producer platform, consumers [sales, growth, platform-warehouse], owning contract contracts/identity/README.md, and an active v1 schema at contracts/identity/schema/payloads/person.updated-v1.json. Adding delivery, revenue, or coaching to the consumer list later is an additive minor change per envelope contract §8.1 and does not require a contract version bump.
The payload schema carries the full post-update canonical Person (nine fields per person-canonical-fields.md), a changed_fields array enumerating which canonical fields actually changed (given_name, family_name, display_name, status, is_minor), and a prior_status field populated only when status was among the changes. Consumers filter on changed_fields to skip irrelevant traffic; an archive subscriber short-circuits unless changed_fields includes status and the new person.status is archived.
PATCH /api/identity/v1/person/[personId] ships
The route accepts a partial body covering given_name, family_name, display_name, and status. Status accepts active or archived; transitions to merged are rejected with a clear error (merge goes through the merge service, which sets aliasOf and runs the externals reattachment in one transaction per person-externals.md §5.4). No-op patches return the existing Person without emitting an event, per the payload schema's minItems: 1 on changed_fields. Auth is the existing requireSession guard.
Transactional emit
The row update and the person.updated publish run in a single Prisma transaction per ADR-0009 producer-transactional guarantee. A Person row that updated without an event, or an event emitted without the row update, is not observable. The daily runIsMinorInvariantJob does the same per-Person: each is_minor flip is its own transactional unit with its own person.updated emit, with changed_fields: ["is_minor"].
What this enables and what it does not
For the immediate use case (operator wants to take a test Person off the active surface in dev or staging), the flow is now: PATCH the Person with { "status": "archived" }, Platform writes the row and emits person.updated inside the same transaction, the dispatcher puts the event on the Postgres queue, and Sales and Growth consumers receive it on the next poll. That is the producer half.
The consumer half is what this memo is asking for. Sales and Growth need subscribers that actually do something on receipt. Without them, the event lands in dispatcher_event and dies there, the operator's status: archived writes through on Platform, and Sales' Lead plus Growth's acquisition queue still treat the Person as if nothing happened. That is the gap we are closing.
Asks
Two consumer commitments, one per domain. Each commitment belongs to the domain that owns the consumer code, so the file response is a memo from Sales (and a separate one from Growth) on this thread declaring the commitment in that domain's frontmatter, per the OPERATOR rule on memo ownership.
Sales
Sales subscribes to person.updated, filters on changed_fields.includes("status") && person.status === "archived", and applies the archive semantic to any Lead keyed on person_id. Recommended interpretation per the canonical-fields contract: drop the Lead from active cadence, do not enqueue new outbound attempts, do not initiate new comms. The Lead row itself stays for history; the lifecycle state Sales transitions the Lead into (disqualified, closed-archived, paused, your call) is a Sales lifecycle decision, not a Platform contract requirement. Reactivation if status flips back to active is also Sales' choice.
Wire-up cost is small: a subscriber script under sales/scripts/, a handler that touches the Lead's stage and cadence runtime per the lead-lifecycle contract, and an integration test against a fixture event. The dispatcher SDK consumer surface (dispatcher.subscribe, dispatcher.start) is live per memos/2026-05-01-platform-dispatcher-sdk-build-plan Phase 2 Slice 3.
Growth
Growth subscribes to the same event with the same filter and applies the archive semantic to acquisition messaging. Recommended interpretation: drop the person_id from any active outbound campaign segment, do not retarget on the visitor stitch through to that person_id, but keep the historical growth.touchpoint rows intact (history preservation is explicit in the canonical-fields contract). New touches under a new visitor_id that later stitch to an archived person_id resolve as attributed-but-archived and should be excluded from acquisition reporting but counted in historical analytics.
Wire-up cost is similar: a subscriber under growth/scripts/, a handler that touches the messaging-eligibility flag, and a fixture test.
Reply shape
A short reply memo from each domain declaring the commitment in frontmatter, in the standard five-part format. Suggested shape (each domain edits to taste):
commitments:
- "|pending|P1|S|Subscribe to person.updated; on changed_fields.status && person.status archived, drop Lead from active cadence"
Both subscribers are independent of each other and can land in either order.
Detail
Why merged is not a legal status update target
The contract treats merge as irreversible per person-resolution-semantics.md §merge, and the merge flow does additional work the update path does not: it sets aliasOf on the merged row, promotes nullable fields from the alias to the canonical, and reattaches person_external rows in the same transaction. Letting an operator PATCH status: merged through the route would bypass all of that. The route rejects with a clear BAD_REQUEST naming the merge service as the right path.
Why the actor defaults to system:platform
The PATCH route runs under an authenticated session, but the session carries a User row id (better-auth), not a Person id. Mapping User to Person is not yet wired; until it is, the route passes no explicit actor and the service defaults to system:platform so the event still rides on a valid ActorRef. When operator-as-Person identity becomes available, the route will pass the operator's per_<UUID> through to updatePerson(personId, patch, { actor }) without any consumer-side changes (consumers do not gate on actor today and would still see the event arrive).
Why consumers filter on changed_fields
The same event_type carries name edits, status transitions, and is_minor flips. A consumer that only cares about archive semantics ignores name edits; a warehouse loader ingests all of them. Filtering on changed_fields is the contract-correct way to scope a handler. The payload schema's enum over changed_fields items is the source of truth for what values can appear.
Next
Sales and Growth each file a one-page reply memo on this thread declaring the consumer commitment in frontmatter. Once both subscribers land and the integration tests pass, the propagation is end-to-end and the operator can use the PATCH route for real test-data cleanup without dropping to the database.
If either domain wants the consumer wire-up walked through (the dispatcher SDK consumer surface, the registry contract for filtering, the fixture-event pattern), file a follow-up memo and Platform will produce a short worked example against a domain repo of your choice.
References
- Identity contract:
contracts/identity/README.md - Canonical fields spec:
contracts/identity/person-canonical-fields.md - Resolution semantics (merge irreversibility, archive semantic):
contracts/identity/person-resolution-semantics.md - Person graph (no domain stores Person; cache-or-fetch with event-driven invalidation):
contracts/identity/validation/person-graph.md - Event envelope contract:
contracts/event-envelope/README.md - ADR-0003 (Person canonical entity)
- ADR-0009 (producer-transactional guarantee)
- Dispatcher build plan:
memos/2026/2026-05-01-platform-dispatcher-sdk-build-plan.md - Payload schema:
contracts/identity/schema/payloads/person.updated-v1.json - Event-types registry:
contracts/event-types-registry.json