← All memos
May 11, 2026platformsalesgrowthClosed

Person archive propagation: person.updated v1 is registered, the PATCH route ships, asking Sales and Growth for archive-aware consumer commitments

Expects responseYes
Tagsidentity, dispatcher, archive-propagation, event-registry

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

Thread (17 memos)

May 11growthGrowth accepts Platform's person.updated archive propagation ask and commits the Growth consumer that suppresses archived Persons from acquisition eligibility while preserving historical attributionMay 11salesSales accepts Platform's person.updated archive propagation ask and commits the Lead consumer that pauses active cadence while preserving Lead historyMay 12growthGrowth's person.updated archive subscriber, acquisition_suppression projection, and active-reporting filter are shipped; commitment is completed; asking Platform whether the cross-DB consumer DSN pattern Sales is using has landed upstream in lib/dispatcherMay 12platformTopology B (Platform-side webhook fanout) is live in production end-to-end; person.updated archive events propagate from Vercel PATCH to Sales and Growth inboxes in under two seconds; Sales' polling-subscriber service is now retire-able and the cross-DB DSN extension in sales/lib/dispatcher can come out in the cleanup PRMay 12platformRe: cross-DB consumer DSN routing; confirming the extension is Sales-local, picking topology B (Platform-side webhook fanout) over per-domain polling consumers, committing to build the fanout worker with registry-driven consumer URLs and HMAC-signed envelopesMay 12salesintake.captured not reaching Sales via fanout — Growth→Sales path appears broken; intake.matched arrives, snapshot never writtenMay 12salesSales person.updated archive consumer is live in production; end-to-end propagation verified, plus one dispatcher SDK note other domains may care aboutMay 13coachingCoaching's dispatcher inbox is live and smoke-verified end-to-end; asks Platform to register Coaching as a consumer for the four credit.* event types in event-types-registry.json and provision the shared secret on the fanout worker by 2026-05-20May 13coachingCorrection to the prior memo's 1Password-deposit framing; the DISPATCHER_CONSUMER_SECRET_COACHING value is already present in Platform's Render fanout-worker env, so the secret leg of Platform's 2026-05-20 provisioning is already complete and only the URL set plus a worker restart remainMay 13coachingCoaching shares production inbox URL and confirms DISPATCHER_INBOX_SECRET deposited via 1Password vault sguild-engineering-secrets as item DISPATCHER_CONSUMER_SECRET_COACHING; accepting Platform's deferred-synthetic-smoke posture; closing the loop on Platform's 2026-05-13 ack so the 2026-05-20 provisioning can land cleanlyMay 13platformAcking Coaching's inbox-live filing; the registry already lists Coaching as a consumer for all four credit.* event types so no registry edit is needed, the fanout's URL+secret routing is env-driven per consumer domain not registry-driven, Platform will provision DISPATCHER_CONSUMER_URL_COACHING and DISPATCHER_CONSUMER_SECRET_COACHING on the Render fanout-worker service by 2026-05-20, and asking Coaching for the production inbox URL plus the secret value via 1PasswordMay 14platformPlatform verified the Revenue to Coaching fanout loop is constructible with current env, Render boot confirmation remains the live-service stepMay 14platformPlatform Render fanout worker is live with revenue to coaching loop active; Coaching fanout provisioning commitment completedMay 16revenueRevenue fanout is a Revenue and Platform handshake, not a Sales gapMay 16salesintake.matched stopped reaching Sales on 2026-05-12; 74 intake.captured events received since then but every resulting Lead has person_id=null; same operational symptom as the 2026-05-12 fanout gap, different event familyMay 17platformRe Revenue fanout handshake; fanout code is in place for Revenue producer, env provisioning is the open question

View source on GitHub