Ownership — slots, eject, and the never-clobber writer
This is the implementation design for packages/maxstack-core/src/ownership/,
the Phase 2 "safe change-over-time" machinery.
It is new work built on the eject reference spec
(the archive design) and the
provenance reference spec (the data-layer
invariants), not another archive extraction.
Phase 2 "ships the hard guarantees first, then runs both candidate mechanisms side by side". The design stands up on one small target — a resource list page with one slot — end to end.
This is the design document for the machinery. The user-facing change ladder
these mechanisms serve (spec-op — including the theme/page.setBlockVariant
presentation ops → slot-fill → eject → regeneration-as-diff) is
stated canonically in user-guide §5.
The hard guarantees, true under either mechanism #
- Ownership manifest (
manifest.ts) —.generated.routes.json, one entry per file withownership: 'generated' | 'ejected' | 'user'plus a content hash. Reimplements saaskit-one-ejectable's flatstring[]as per-entry ownership so regeneration can tell a stale generated file from a hand-touched one. - Never-clobber writer + eject (
write.ts) — every write consults the manifest;ejected/userfiles are never overwritten.ejectcopies with a banner, strips the generated banner, and flips ownership — including when it ejects in place, which is the default (maxstack eject <id>with no--to): the file being rewritten there is the framework's own, so swappingAUTO-GENERATED … DO NOT EDITforEJECTED — you own this file nowclobbers nothing and stops the file claiming something the manifest just made false. A different destination that already exists is still left untouched. Filesystem is an injectedFsport (memfs.tsis the in-memory double + CLI dry-run backend), so L2 keeps no hardnode:fsdependency. - The ownership drift report (
drift.ts) — the other half of the eject bargain. Never-clobber answers "will you touch my file?"; this answers "what am I missing by owning it?" Everyejected/userentry is compared against what the generator would emit today and classified (in-sync/drifted/authored/underived/missing), with a unified diff for the drifted ones. Every family the generator emits is derived, not just pages, so each entry carries the seam it came from andunderivedexplains itself in that seam's terms. It never writes and never fails a gate — drift is the cost of owning a file, made visible, not an error. Surfaced bymaxstack drift, theownership_driftMCP tool and the workbench's Ownership pane, all over the same fold. Seeupgrade-safety.md. - ts-morph generator-side emission (
emit.ts) — replaces the string.replace('pages: []', …)splice that broke every prior generation.addRouteToManifestinserts a route through the AST array node: structural, idempotent, and immune to anchor-string corruption (there is a test that proves a comment containing the old anchor survives).emitResourcePageemits a deterministic, slot-bearing route module. - Regeneration-safety suite (
regen.ts) —checkRegenSafety(file-level never-clobber + ownership-preserved) andcheckProvenanceInvariants(data-level manual-survives + grounds-on-accepted, kept structural so L2 needn't import the L1 spec package). §6: "100%, not a target."
Mechanism (a) — the ladder: cross-file extension slots #
The <Slot> runtime lives in @maxstack/ui (slots/Slot.tsx); the generator
wires it. A generated page renders <Slot name="afterList" render={slots.…} />
from a stable user-owned *.slots.tsx file. Part-generated /
part-hand-written at the module boundary — the user owns their slot file
whole, the generated file regenerates freely, no AST merge. The generator
writes the slot stub exactly once (writeUserFileOnce), then never touches it.
The end-to-end example (packages/ui/src/slots/example/) is its own
case: a "bulk-archive" button that needs a slot, not a spec op. A render test
proves the generated page + user slot file compose; a generate test proves a
regeneration with a changed title leaves the user-edited slot file
byte-identical.
Block-level slots #
The seam above is page-level: a slot exists because someone put a
slot:<name> block in the spec, and wanting one custom card meant ejecting the
whole surface. block-slots.ts adds a second kind, derived rather than
declared: every resource page exposes a fixed, versioned set of slot-bearing
block roles (header, list, row, field, empty), and a slot is filled
the moment the same user-owned *.slots.tsx exports the derived id
(exercise__row, task__field__dueDate).
Three properties carry the design, and each has a test:
- Ids derive from spec identity alone — resource + role (+ field), never generation order or block index — because a filled id is a public API.
- Available, not scaffolded. The generator never seeds a block-slot stub;
maxstack slots fill <id>(fillBlockSlot) writes the one you ask for, append-only. Otherwise every project's slot file would carry a placeholder per role per resource. - Orphans are gated. The dangling-reference check has a mirror image that
block slots make possible — an implementation with no host block, which
fails silently.
orphanedSlots()failsmaxstack validate, and the harness countsorphanedBlockSlotsper benchmark.
Full user-facing treatment: block-slots.md.
The non-page seams, and who emits them #
Schedules, external sources
(external-sources.md), import parsers
(imports.md) and bespoke live surfaces (live.md)
each generate the same two-file shape as a page: a framework-owned registry
(jobs/schedules.generated.ts &co) plus a user-owned stub written once.
maxstack gen emits all four, from the same descriptor projections
maxstack drift derives its targets from (scheduleDescriptors and friends in
@maxstack/mcp) — so the CLI, the drift report and the harness cannot disagree
about what a seam is. That agreement is pinned over the whole corpus by
apps/maxstack/src/lib/seam-derivation.test.ts, because the harness keeps its
own copy of those projections (the boundary policy forbids it importing
@maxstack/mcp).
Each generator's absence rule holds throughout: a project that declared no
schedule grows no jobs/ directory to prove it — and, since the absence rule
runs in both directions, a project that stops declaring one loses the registry
it grew. Undeclare one schedule of three and the registry is re-emitted with the
survivors; undeclare the last one and the registry is deleted along with the
manifest entry the runtime imports it through. The handler, refiner, parser or
surface itself is never deleted: it is your code, and losing its registration
is what stops it running. gen prints a line naming each one, so an unwired
handler is something you were told about rather than a file that went quiet.
The registries reach the runtime through the same Bar 2 seam owned slot code
does — owned.generated.tsx re-exports each one (OWNED_SCHEDULE_HANDLERS,
OWNED_SOURCE_REFINERS, OWNED_IMPORT_PARSERS, OWNED_LIVE_SURFACES), so a
handler somebody filled in actually runs instead of dead-lettering against an
empty registry. All four are consumed today:
| registry | what reads it |
|---|---|
OWNED_SCHEDULE_HANDLERS |
the job queue (sprout.server.ts) |
OWNED_IMPORT_PARSERS |
an upload to a format: 'custom' importer |
OWNED_SOURCE_REFINERS |
the source runner (sources.server.ts) |
OWNED_LIVE_SURFACES |
the bespoke-surface host (live-surface.tsx) |
The last two were exported and read by nothing until later, because there
was nothing to read them: registerSourceHandlers was called nowhere and no
surface rendered a slotted channel's component. Those were missing execution
paths rather than missing wiring, which is why closing them was a feature and
not a fix — see external-sources.md for what triggers a
run and live.md for where a bespoke surface composes into a page.
Mechanism (b) — regeneration-as-diff (bet B) #
regenerateAsDiff re-derives affected files and returns a reviewable unified
diff (via diff) instead of overwriting. Protected (ejected/user) files are
surfaced but never proposed for overwrite — never-clobber enforced before a
write is even contemplated. applyReviewedDiff lands the batch only if the
injected validate gate passes; a failing gate rolls back to a no-op (nothing
was written yet).
The comparison #
Both mechanisms enforce the same regen-safety floor. The ladder makes user customization structural (own a whole slot/ejected file); regeneration-as-diff makes it reviewable (approve a diff, hand edits preserved). Whole-file eject is the hard guarantee under either. The Phase 2.5 dogfood + the harness eject/edit-rate signal decide which is load-bearing.
Not yet wired (follow-ups) #
- Drive the generator from a real Sprout resource +
mconfigpage (currently a hand-builtPageDescriptor); today's small target is the list page. - A
node:fs/promisesFsadapter + themaxstack eject/genCLI verbs over this core (the verbs are documented stubs — task 5). - Multi-line array formatting from ts-morph's printer (routes manifest emits a single-line array; correct, not pretty — Biome would reformat in a real project).
Interpreting an ownership-safety failure (CI) #
The ownership-safety job in .github/workflows/ci.yml runs on
every PR and guards the two product promises independently of the main
validate gate:
- Ownership invariant unit suite —
pnpm --filter @maxstack/core test. A failure here means a change broke never-clobber, manifest, eject, or emit behavior directly; the vitest output names the invariant. - Regen-safety eval — the maintainer's regeneration sweep
across every benchmark. It exits non-zero when any benchmark's
regenSafetyPassRatedrops below 1.0 (a regeneration clobbered or dropped a user/ejected file) or a deterministic generator drifted between runs.
Triage from the uploaded artifacts:
ownership-safety-results— the JSON summary (uploaded on every run). Find the failing benchmark id inresults[]:regenSafetyPassRate < 1means a clobber;deterministic: falsemeans drift.ownership-safety-trees(failure only) — each benchmark's generated tree under<benchmark-id>/. Diff the flagged files against the benchmark's expectations to see exactly what a regeneration would have destroyed.
A red ownership-safety job is never noise: either fix the regression or, if
the benchmark's expectations were legitimately changed, update them in the
same PR with the reasoning in the description.