Skip to content

Commit bcb6a17

Browse files
hotlongclaude
andauthored
docs(adr): ADR-0094 D2 names three projecting doors; the shipped code has seven (#15249)
The mutation projector is awaited at seven call sites in the metadata protocol, not the three D2 enumerates: the recovery doors (rollbackMetaItem, both limbs of revertCommit) and deleteMetaItem's legacy raw-engine exit project too. Record it as a dated amendment in the ADR's own style, leaving the original sentences visible and marked rather than rewritten. Also corrects the D2 clause that reads as though boot reconciliation is the only thing that heals a failed projection, notes that the publish door is a shared helper reached by two callers, notes that three of the seven sites surface no projectionApplied, and adds the forward pointer to ADR-0131's v18 retirement. Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0ec2370 commit bcb6a17

1 file changed

Lines changed: 105 additions & 2 deletions

File tree

docs/adr/0094-sys-permission-set-pure-projection.md

Lines changed: 105 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ not by a subscriber a new write path might forget to trigger:
3030
`publishMetaItem` / `deleteMetaItem`, after persistence and before the write returns.
3131
The projector is the **only writer** of the record. A Studio save therefore returns
3232
only after the record already reflects it — no projection race (the #2867 subscriber
33-
was fire-and-forget).
33+
was fire-and-forget). (**The three doors named above are no longer the whole list**
34+
the recovery doors project too since 2026-09-03; the sentence is left as written and
35+
the full enumeration is in the 2026-09-04 amendment at the end of this record.)
3436
3. **Boot reconciliation + one-time backfill.** At `kernel:ready` the projection is
3537
re-derived from metadata (metadata wins), and legacy records that exist **only** in
3638
the data plane are migrated into the metadata store once.
@@ -91,7 +93,7 @@ recreates ids.
9193
**Assignments and bindings stay data-plane.** Which users/positions hold a set is
9294
environment *state*, not part of the definition; those tables are unchanged.
9395

94-
### D2 — The record is written only by the projector, awaited by the protocol
96+
### D2 — The record is written only by the projector, awaited by the protocol (door enumeration amended 2026-09-04 — see the amendment at the end)
9597

9698
`@objectstack/metadata-protocol` gains `registerMutationProjector(type, fn)`: an
9799
awaited, best-effort per-type hook invoked after persistence inside `saveMetaItem`
@@ -100,6 +102,14 @@ awaited, best-effort per-type hook invoked after persistence inside `saveMetaIte
100102
write's response (`projectionApplied: { success:false, error }`) and logged — never
101103
thrown, the metadata write itself succeeded and boot reconciliation heals on next start.
102104

105+
> **Amended 2026-09-04** (see the amendment at the end of this record): the two
106+
> sentences above are narrower than the shipped code in two ways. The door list is
107+
> three of **seven** call sites, and boot reconciliation is not the only thing that
108+
> re-derives a record a failed projection left stale — every projecting door does,
109+
> and since 2026-09-03 that set includes the recovery doors. `projectionApplied` is
110+
> also not surfaced at every site: three of the seven call and await the projector
111+
> without putting an outcome on the response.
112+
103113
`plugin-security` registers the `permission` projector. It re-reads the **fresh layered
104114
effective body** and:
105115

@@ -484,3 +494,96 @@ two rules the `object` authoring gate (`objectPostureGate`,
484494
Door order, as pinned in `packages/rest/src/meta-object-owd-gate.test.ts`:
485495
the 422 lint gate answers first; this seam's 403 R1 door answers for writes
486496
that pass lint.
497+
498+
## Amendment (2026-09-04, #15244): the projector has seven doors, not three — and boot reconciliation was never the only re-derivation
499+
500+
D2 enumerates the doors that await the projector as `saveMetaItem` /
501+
`publishMetaItem` / `deleteMetaItem`. That sentence was accurate when this
502+
record was written and is **incomplete** on today's tree: PR #14982 (merged
503+
2026-09-03 as `95464ed65`) extended the projection to the recovery doors, and
504+
the record did not follow it — that PR's documentation half was closed void, so
505+
the code shipped alone. This amendment closes the gap and **changes no
506+
decision**: D1 stands untouched, and D2's rule — *the record is written only by
507+
the projector, and the projector is awaited before the write returns* — is
508+
exactly what the four new call sites implement. Only the enumeration was wrong,
509+
which is the harder kind of error to notice, because nothing it says is false.
510+
511+
### The seven projecting doors, measured on `main`
512+
513+
`runMutationProjector` is declared once in
514+
`packages/metadata-protocol/src/protocol.ts` and called from **seven** sites in
515+
that same file — D2's three, plus the four #14982 added:
516+
517+
| Projecting door | `state` passed | Body handed over | Outcome on the response |
518+
| :-- | :-- | :-- | :-- |
519+
| `saveMetaItem` | `draft` \| `active` | the saved item | `projectionApplied` |
520+
| `runPublishSideEffects`, the phase-2 publish helper | `active` | the published body | `projectionApplied` |
521+
| `deleteMetaItem`, repository branch | `deleted` | none | `projectionApplied` |
522+
| `rollbackMetaItem`, after its registry write-through | `active` | the restored version's body ||
523+
| `revertCommit`, restore limb | `active` | the pre-commit body ||
524+
| `revertCommit`, soft-remove limb | `deleted` | none ||
525+
| `deleteMetaItem`, legacy raw-engine exit | `deleted` | none | `projectionApplied` |
526+
527+
Three things D2's three-name sentence hides, each measured on the call sites
528+
rather than inferred from the method names:
529+
530+
- **The publish door is a shared helper, not `publishMetaItem`.** The projecting
531+
call site lives in `runPublishSideEffects`, at that helper's own body level,
532+
and `publishMetaItem` is one of **two** callers — `publishPackageDrafts`
533+
reaches the same projection through the same helper. D2 named only the first,
534+
so the package-draft publish path has been projecting, undocumented, since
535+
before this amendment.
536+
- **`deleteMetaItem` projects from two exits, not one.** Its repository branch
537+
always did; #14982 added the legacy raw-engine exit, which serves the
538+
code-only types the repository path cannot.
539+
- **`projectionApplied` is not universal.** D2's failure-surfacing sentence
540+
holds at four of the seven. `rollbackMetaItem` and both `revertCommit` limbs
541+
declare no such key on their response types and #14982 deliberately added
542+
none: there the projector is called and awaited, and a failure is logged
543+
without reaching the caller.
544+
545+
The count is enforced, not incidental —
546+
`packages/metadata-protocol/src/protocol.recovery-doors-mutation-projector.test.ts`
547+
pins "exactly seven" call sites, three original and four recovery-door, with the
548+
declaration asserted separately as a non-vacuity control. Anyone can re-derive
549+
the number:
550+
551+
```bash
552+
git grep -c "this.runMutationProjector(" -- packages/metadata-protocol/src/protocol.ts
553+
```
554+
555+
which answers `7` on this tree. If it ever answers anything else, the table above
556+
is what went stale.
557+
558+
### Boot reconciliation is not the only healing path
559+
560+
D2's best-effort clause ends "boot reconciliation heals on next start", which
561+
reads as though a failed projection is stranded until the next boot. It is not,
562+
and it never quite was: **any** later projecting door on the same name re-derives
563+
the record, because the projector re-reads the fresh layered effective body
564+
rather than trusting whatever it was handed. Before #14982 that set was an
565+
unrelated save, publish or delete; since #14982 it also includes a rollback and
566+
both limbs of a commit revert. D4's `kernel:ready` pass remains the guaranteed
567+
floor — the one heal that needs no subsequent write, and the property D2's
568+
sentence was reaching for — but it is a floor, not the only path.
569+
570+
The inverse defect is the one #14982 fixed. Before it, the recovery doors
571+
restored the row and the in-memory registry without projecting, so a rollback
572+
left the derived record on the rolled-back-from state: applied everywhere except
573+
the record Setup reads, until an unrelated write or the next boot caught up.
574+
575+
### This mechanism has one major left to live
576+
577+
[ADR-0131](./0131-total-organization-ownership-no-null-organization-id.md)
578+
(merged 2026-09-04 as PR #14976) retires it. Its D2 names "the
579+
`sys_permission_set` projector and reconciler of ADR-0094 D2/D4" among the
580+
machinery that goes, and its D3 retires the `sys_permission_set` object itself
581+
(with D13) — on the ground that **D1 of this record was right and is being
582+
generalized**: the metadata layer was already the sole authoritative store, so
583+
once the catalog is read from the registry the projected row has nothing left to
584+
be. D14 puts that on the **v18** line, and the card that executes it is C3,
585+
tracked on #15204.
586+
587+
So this amendment describes a mechanism with one major to live. It is written
588+
for whoever has to keep the seven doors correct until then — not as an
589+
invitation to build something new on top of them.

0 commit comments

Comments
 (0)