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
{{ message }}
Repository navigation
Commit b1aee33
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol)!: refuse a flow saved into a package this deployment has not installed (#20863) (#20959)
Fixes#20863
Clause-②: no (narrowing)
## What this does
A flow saved through the metadata door (`PUT /api/v1/meta/flow/:name`)
may name, as its base, the package it belongs to. When that id was a
package this deployment has never installed, the door answered `200`,
stored the flow live, and served the binding back: a flow bound to a
package that does not exist. It now answers `422
WRITABLE_PACKAGE_REQUIRED`, and nothing is written, served or
registered.
- **One rule, extended in place.** The refusal lives in
`tenantAuthoredWriteRefusal`
(`packages/metadata-protocol/src/protocol.ts`), the one shared function
every flow write door asks, which PR #20853 established. There is no
second check. The rule gains a named-base arm between the locked-base
lock and the provenance check. `saveMetaItem` already handed the rule
the base it names, so its code is unchanged; only its comment at the
hand-off says so.
- **"Installed" is the set the metadata write path already resolves a
base against.** The arm asks `resolveWritePackageScope`, the registry's
package read that the runtime authoring gate uses for its package
closure. It is not the loader's managed set alone: a tenant's own
writable base, created through the package door and rehydrated from the
package store at boot, is installed and ships no flow. No new registry
read and no second list of packages.
- **Whatever the definition carries.** Both branches that used to admit
the save now refuse it: a definition with no provenance of its own, and
one whose provenance names that same missing package.
- **Order.** A shipped flow is still refused as a locked base first
(#20679's check, reused). With the operator's writable-types hatch open,
the lock admits a shipped flow, and a missing base is still refused: the
hatch unlocks a type, never a binding.
- **Scope.** `flow` only, as the #20761 ruling's rule 5 requires. Every
other type keeps its old handling. The `/automation` create, update and
clone doors name no base, so they do not move. The two server-stated
rewrites (stored-metadata migration and package duplication) are not
judged by the rule, as before.
- **The stored-row sentinel is not a package.** A save naming it is a
package-less save, admitted as before.
## Hypotheses from the dispatch, measured
- **H1 held.** Measured on `origin/main` `31c39964fc` against the
unmodified rule, with an environment id and without. A definition with
no provenance returns `null` at the "body not code-shipped" branch. A
definition whose provenance names the same missing id returns `null` at
the "named base equals the stamp" branch. The ADR-0070 D1 gate further
down then admits the save, because its writability predicate reads an
unregistered id as a writable authoring workspace. At the door, the
ablation's mutated leg below re-measured it on the showcase: `200`,
state `active`.
- **H2 held, with one qualification.** The reader exists:
`resolveWritePackageScope` (registry `getPackage`), reused inside the
rule. The qualification is that its `undefined` covers both "the
registry does not hold this id" and "the registry cannot be read" (a
registry with no package read at all, or one that throws). So on such a
registry a named base is refused: the fail-closed direction, the same
one the automation engine takes with no loader's-set reader. Every real
composition's `SchemaRegistry` has the read. This is written down in the
rule's docblock.
- **H3: `WRITABLE_PACKAGE_REQUIRED` / 422.** Ledger row:
`packages/spec/src/api/error-code-ledger.zod.ts:634`, in the
`@objectstack/metadata-protocol` block (line 590). No code is minted.
ADR-0070 D1 decided this code for exactly this condition: a runtime
create whose resolved base is missing or read-only. Its remedy is the
one this caller needs: choose or create a writable base, or name none.
`INVALID_METADATA` (line 610, same block) was rejected because the
definition may be perfectly valid; what is wrong is the base the request
names, not a key in the body. The sentence is new, because the D1
emitter's sentence says "read-only", which is false for a package that
does not exist. The refusal carries the refused id and the ADR-0070 docs
pointer, as that emitter's does.
- **H4 measured, one topology NOT MEASURED.** The in-repo environment
kernel is the standalone stack (`createStandaloneStack`, environment id
`env_local`). One-shot boot, not kept as a file, through the kernel's
protocol service: both definition shapes answered
`WRITABLE_PACKAGE_REQUIRED/422`, 0 metadata rows, registry item absent.
The two controls saved `active`: no base named, and a base installed
through `installPackage`. The unit pins also run every case with an
environment id. The cloud's per-environment kernel manager is not in
this repository: NOT MEASURED.
- **H5 held and pinned.** Unit level: the store's insert and the
registry's `registerItem` are never reached, for published and drafted
saves, on both topologies. Door level: 0 `sys_metadata` rows under the
name, the metadata read answers `404`, and the automation read answers
`404` (the engine never armed it).
## Pins
-
`packages/metadata-protocol/src/protocol.tenant-authored-write.test.ts`,
a new describe block with 7 cases: the refusal across 5 definition
shapes and both topologies, plus the plural type spelling; nothing
written or registered on published and drafted saves; three controls (no
base or the sentinel passes, an installed base passes, a shipped flow is
a locked base first whatever base is named); the hatch case; and another
type left untouched. The rule's registry double now serves the
registry's package read.
- **One door-level pin** on the showcase host-config boot, in the
existing
`packages/qa/dogfood/test/flow-provenance-server-held.dogfood.test.ts`:
one `it`, so no second boot. Triage's door-level controls are the cases
already in that file: a customer flow with no base saves, one in the
tenant's installed base saves, and a shipped flow is refused as a locked
base.
- **Fixtures re-judged, because they saved flows into a package their
registry never held.**
- `protocol-publish-drafts-advisories`,
`protocol-publish-drafts-closure` and
`protocol.publish-item-rebind-announce` now declare their base
installed, with no namespace and no dependencies. The prefix pre-flight
and the closure are unchanged, and no assertion moves.
- `protocol.package-closure-gate`'s "narrows nothing when the registry
cannot produce the written package" pinned the very branch this change
shuts for a flow save. It now reaches that state the way it still
arises: a draft promoted after its package left the registry, beside the
installed control, which reports.
## Evidence
All runs are on HEAD `e201d770b2` unless noted.
- metadata-protocol, full suite at `f051bba0e2`: 194 files passed and 3
skipped; 2886 tests passed and 19 skipped; exit 0. `typecheck`: exit 0.
The one file changed after that,
`protocol.tenant-authored-write.test.ts`, re-ran at `e201d770b2`: 19/19,
and `typecheck` exit 0.
- Consumers, because the wire answer changed:
- objectql full suite: 349/349 files, 6812/6812 tests.
- runtime full suite: 300/300 files, 5000 passed and 5 skipped.
- rest full suite: 245/245 files, 4878 passed and 114 skipped.
- These three ran at `eb25706394`, before the #20942 merge.
- At `f051bba0e2`:
- dogfood, 4 flow files (this pin's file, #20942's
`flow-shipped-name-stored-row-boot`, the clone door and the durable
doors): 32/32.
- runtime, 6 automation and `/meta` files: 312/312.
- objectql's publish-conformance file: 15/15.
- `typecheck` exit 0 for objectql, including its test-layer check, and
for dogfood.
- **Red/green ablation of the new arm** at `e5914e3f2c`, through
`scripts/ablation-replace.mjs`, whose restore is armed on exit:
- The mutation made the arm's condition unsatisfiable. On disk: marker
count 1, guard text count 0. Both dists that carry the arm were rebuilt,
`@objectstack/metadata-protocol` and `@objectstack/rest` (rest bundles a
copy). `ablation-dist-preflight` found the marker in both dists.
- Mutated leg: the unit file read 3 failed, 16 passed of 19 (the three
refusal cases). The dogfood file read 1 failed, 13 passed of 14: the new
pin, answering `200` with state `active`.
- Restore: the blob equals HEAD, `git diff HEAD` is empty, and `git
status --porcelain` is empty. After rebuilding both, `--absent` passed
for both dists. The files read 19/19 and 14/14.
## Gates
- `node scripts/pm/dispatch-gates.mjs --commands` derived 68 commands at
`e201d770b2` from a tree that was not stale. All 68 were run, and every
one exited 0.
- `--ran` reconciliation: 68 derived, 68 run, 0 NOT-MEASURED, 0 UNRUN.
- Two first readings were not measurements and were re-run:
- `check:dual-build-cjs-loads` answered PREREQUISITE NOT MET (exit 3),
because 8 packages outside this closure had no `dist/`. They were built,
and it exited 0.
- `check:engine-double-contract` refused an `update` double this PR had
added to the rule's test. The double was dropped rather than growing the
pinned ledger, and the gate exited 0.
- `check:adr-0087-registration`: one declared-breaking changeset,
disposition `not-required (no-migration-prescription)`.
- `pnpm lint`: a proven narrowing, not a full run.
- (1) Population: all 8 changed `.ts` files are under `packages/**`,
which the `packages/**/*.{ts,tsx,mts,cts}` and `**/*.{ts,…}` blocks of
`eslint.config.mjs` lint.
- (2) `eslint --no-inline-config --format json` over those files: 8
files, 0 errors, 0 warnings, at `e201d770b2`.
- (3) Invariance: the config never enables type-aware linting
(`eslint.config.mjs` lines 326-328), so a verdict on an untouched file
cannot move.
- CI: not awaited.
## Changeset
`.changeset/20863-orphan-package-binding-refused.md`:
`@objectstack/metadata-protocol` `minor`, **BREAKING** under the
launch-window convention, following PR #20907's shape. It carries one
ADR-0087 marker and a line telling the caller what to send instead: an
installed base, or no base. The dogfood package is private. The objectql
change is a test file, which ships nothing.
## Declared deviations
- **Outside the claim's file surface:**
`packages/objectql/src/publish-package-drafts-response-conformance.test.ts`,
+4 lines, in a separate commit (`eb25706394`) that can be dropped on its
own.
- Its harness staged flow drafts into a package its real registry never
held, so any implementation of the ruling turns 5 of its cases red. It
now installs that base, with no namespace. No assertion moves, and the
file reads 15/15.
- The claim did not name it. It is declared here, not taken silently.
- The door-level pin went into the existing #20761 dogfood file rather
than a new file, to avoid a second showcase boot in CI.
- `main` was merged twice (no rebase). The second merge brought #20942
(`75519e1c0a`), which edits `protocol.ts` near this rule. Git merged it
cleanly, and both changes are present.
## Acceptance notes (noted, not filed)
- For every type other than `flow`, the metadata door still stores a row
bound to a package id no installed package holds. The ADR-0070
writability predicate reads an unregistered id as a writable authoring
workspace. This is kept deliberately: the #20761 ruling's rule 5 leaves
other types unchanged. Carrier: none.
- A host with no package store loses a runtime-created base from the
registry at restart. That is an existing degradation, and
`installPackage` states it loudly. After this change, a flow save naming
such a lost base is refused rather than stored bound to it. Not
measured. Carrier: none.
- `@objectstack/rest`'s built `dist/` carries its own copy of this
package's protocol code: the new sentence appears there, and rest lists
`@objectstack/metadata-protocol` as a devDependency. Observed while
scoping the ablation's rebuild. Not investigated further. Carrier: none.
- objectql's publish-conformance harness calls a registry method that
`SchemaRegistry` does not declare (0 hits in `registry.ts`), behind
optional chaining, so those two calls do nothing. This is a reading, not
measured. Carrier: none.
- The `protocol.ts` ADR anchor does not mention the new named-base arm.
It was not added, because the anchor file is outside the claim's
surface. The rule's docblock cites ADR-0070 D1 and ADR-0126 §2. Carrier:
none.
## NOT MEASURED
- The cloud per-environment kernel manager: it is not in this
repository. H4's in-repo environment kernel was measured, as above.
- Studio's round trip: objectui is not in this container. The
server-side refusal is what Studio receives.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DEvba2nBuD4tWzfq8r8NFY)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
fix(metadata-protocol)!: a flow saved through the metadata door naming, as its base, a package this deployment has not installed is refused, instead of being stored bound to a package that does not exist (#20863)
6
+
7
+
Clause-②: no (narrowing)
8
+
9
+
<!-- adr-0087: not-required (no-migration-prescription) No metadata changes shape and nothing an author wrote is renamed or removed, so `objectstack migrate meta` has nothing to rewrite. What moves is which flow saves the metadata door accepts: a save that names a package no installed package holds is now refused. -->
10
+
11
+
**BREAKING**: shipped as `minor` under the launch-window convention. `PUT /api/v1/meta/flow/:name` answered `200` and stored the flow live when the save named, as the package the flow belongs to, a package id this deployment has never installed. It now answers `422 WRITABLE_PACKAGE_REQUIRED`, and nothing is written, served or registered.
12
+
13
+
**What changed.** The one authoring rule every flow write door asks (`tenantAuthoredWriteRefusal`) now also judges the base a save names. After the locked-base refusal and before the provenance check, a named base must be a package the registry holds as installed: a code package, an installed package, or a tenant's own writable base created through the package door. That is the same registry read the metadata write path already resolves a base against, so no second list of packages is kept. The refusal does not depend on what the definition carries: a save with no provenance of its own and a save whose provenance names that same missing package were both stored before, and both are refused now. It applies on a single-kernel host and on an environment kernel alike.
14
+
15
+
- The code is `WRITABLE_PACKAGE_REQUIRED` / `422`, the one ADR-0070 D1 already uses for a runtime create whose base is missing or read-only. The refusal names the package id the save sent.
16
+
-**Unchanged:** a flow saved without naming a package; a flow saved into an installed package; the locked-base refusal of a shipped flow, which still answers first; every other metadata type, whose saves keep their old handling; the `/automation` create, update and clone doors, which name no package; and the server-stated rewrites of stored rows (the stored-metadata migration and package duplication), which the rule does not judge.
17
+
18
+
**What to send instead.** Save the flow into a package this deployment has installed (the package list shows them, and a base that does not exist yet is created first through the package door), or save the flow without naming a package.
0 commit comments