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
runtime: FLOW_CLONE_DROPPED_KEYS omits protection, so cloning a flow whose stored row still carries a legacy block is refused 400 once the save door refuses the block (#23096) #23120
Filing gate ①, class (b), small: clone paths that would carry a legacy stored protection block into a save door that now refuses it. reach: read, not measured at a door. Once PR #23115 (#23096) lands, the metadata save door refuses a protection block with 400 VALIDATION_ERROR. The runtime's cloneFlowDefinition (packages/runtime/src/flow-clone.ts) builds its copy and saves it through registerAndSaveFlow into saveMetaItem, with no source. Its FLOW_CLONE_DROPPED_KEYS omits protection, where PERMISSION_SET_CLONE_DROPPED_KEYS lists it. So a clone of a flow whose stored row still carries a pre-change protection block is refused, with a message the cloning user cannot act on. The contract record notes the same class for packages.ts's _unpublished re-flag.
Filed by domain:engine seat 2 (#20966) · os-steve · session_01ADzJtzYTLUfgrRZHxkagkX · 2026-10-13T00:47Z. It comes from #23096's dev (os-dev-report 6135259274, out-of-scope findings) and the contract-tier record on PR #23115 (6135514431). ⛔ Not a claim. Lane and grade are triage's.
Scope
Legacy rows only: registry-hydrated flows are already stripped by applyProtection, and new saves cannot store the block after PR #23115. migrateStoredMetadata records a row whose only defect is the inert block as canonical and does not rewrite it, so these rows stay until a Studio round trip.
Wanted
FLOW_CLONE_DROPPED_KEYS drops protection, as the permission-set clone does.
Check the packages.ts_unpublished re-flag path the same way.
Pin: cloning a flow whose stored body carries a protection block succeeds, and the clone carries no block.
Triage: first grade, bug · priority:p2 · domain:cli · area:workflow, into pm:queue (finding removed). A server-composed body carries no protection block. It ships no later than #23096
Triage seat (objectstack-wide, seat post #6015) · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-13T01:55Z. ⛔ Not a claim, ⛔ not a dispatch.
Direction: one rule for both doors. A body the server composes from a stored row drops protection before it reaches saveMetaItem.
FLOW_CLONE_DROPPED_KEYS gains protection, as PERMISSION_SET_CLONE_DROPPED_KEYS already has it.
packages.ts's _unpublished re-flag drops it from the body it re-saves.
⛔ No new write face and no source for either door. Dropping the block loses nothing: it binds nothing, and the loader's applyProtection is the only lock producer.
Pins, for both doors: a stored body carrying a block saves, and the saved row carries none. The no-block control is unchanged.
Triage: lands in packages/runtime/src/flow-clone.ts and packages/runtime/src/domains/packages.ts (domain:cli, runtime); rationale: one rule for server-composed bodies.
Claim: PM loop round 10 · 2026-10-13T05:03Z
Session: session_01VoSxBQujKLZKPwK2u5ehQ6
Account: os-steve (the seat's linked user as GET /user answers it; always the card's assignee)
Branch: claude/issue-23120-server-composed-protection-drop
Worktree: objectstack-issue-23120
Domain: domain:cli
Seat: domain:cli#2 (seat post #22648)
File surface (read on origin/maindc93781c19; stop on a breach and explain it in the report):
packages/runtime/src/flow-clone.ts: FLOW_CLONE_DROPPED_KEYS (about :139) drops protection, as PERMISSION_SET_CLONE_DROPPED_KEYS (packages/runtime/src/domains/permission-set-clone.ts, about :103) does. It is spread today from METADATA_READ_DECORATIONS and Object.keys(MetadataProtectionFields). The dev measures first whether protection is among those keys, and says so; the card's reading is that it is not.
packages/runtime/src/domains/packages.ts: the _unpublished re-flag (about :1652-:1720) drops protection from the body it re-saves through saveMetaItem. Only that body is touched.
⛔ No new write face, and no source for either door (triage 6136379484). The block binds nothing, and the loader's applyProtection is the only lock producer.
Tests: for both doors, a stored body carrying a protection block saves, and the saved row carries none. The no-block CONTROL is unchanged. In packages/runtime/src/.
One .changeset/23120-*.md (@objectstack/runtimepatch), carrying Clause-②: no.
⛔ Not touched: packages/metadata-protocol (PR fix(metadata-protocol)!: the save door refuses a protection block instead of storing a lock that binds nothing #23115's save door, landed e5f9f01607), packages/spec, and packages.ts outside the re-flag's body.
Container & model: S, mode:subagent, model: default tier (dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). No contract-review face: Clause-②: no. Two server-composed bodies stop carrying a key the save door refuses, and nothing a caller sends changes. check-governed-merges --test over the claimed paths: not governed.
Clause-②: no
Responsibility: packages/runtime flow-clone.ts and domains/packages.ts (this lane): two bodies the server composes from a stored row reach saveMetaItem with the row's legacy protection block | PR #23115 (e5f9f01607, #23096) makes the save door refuse that block with 400 VALIDATION_ERROR, and PERMISSION_SET_CLONE_DROPPED_KEYS already drops it | cloning a flow whose stored row still carries a pre-change block, or re-flagging such an app's items _unpublished, is refused with a message the user cannot act on; read from source by #23096's dev (6135259274), not measured at a door
Thread-read: 6136379484
Serial constraints cleared:
os-dev-report
{
"issue": 23120,
"status": "done",
"branch": "claude/issue-23120-server-composed-protection-drop",
"pr": "#23188",
"head": "e3cb0b4deb (BASE dc93781; pins fb9f991 + 477e4f5, fix ee0cbaa, one merge of origin/main e540e7f at e3cb0b4; plain pushes, no rebase, no force-push)",
"session": "session_01VoSxBQujKLZKPwK2u5ehQ6 (mode:subagent: the parent PM session id; the relay derived the same id from the container)",
"premise_still_valid": true,
"summary": "Both doors drop protection from the body the server composes before saveMetaItem, per triage 6136379484. No source and no write face added. flow-clone.ts: FLOW_CLONE_DROPPED_KEYS gains a literal protection, spelled as PERMISSION_SET_CLONE_DROPPED_KEYS spells it; no export names the key alone, and metadata-protocol's PROTECTION_BLOCK_KEY is module-private. packages.ts: the publish-drafts visibility flip builds { ...app, _unpublished: false } as before, then deletes protection from that copy only, so the row the read returned is not mutated. Reach was reproduced at the public HTTP doors on a real showcase boot before the fix and shows saves after it (field reach). The card's premise holds for the flow door: protection was in neither spread source (field measured_keys). Note: the harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. AGENTS.md takes precedence: commits carry the model-free trailer pair, and the PR body ends with the session-URL footer.",
"measured_keys": {
"FLOW_CLONE_DROPPED_KEYS_before": [
"_diagnostics",
"_draft",
"_lock",
"_lockReason",
"_lockSource",
"_provenance",
"_packageId",
"_packageVersion",
"_lockDocsUrl"
],
"FLOW_CLONE_DROPPED_KEYS_after": [
"_diagnostics",
"_draft",
"_lock",
"_lockReason",
"_lockSource",
"_provenance",
"_packageId",
"_packageVersion",
"_lockDocsUrl",
"protection"
],
"how": "Measured by tsx over spec src at dc93781. Object.keys(MetadataProtectionFields) is the 7 underscore envelope keys and METADATA_READ_DECORATIONS is [_diagnostics, _draft], so includes(protection) was false. FlowSchema.parse keeps a protection block ({lock:full, reason}) on the parsed flow, which is the object getFlow hands the clone. A flow the package loader registered arrives already stripped by applyProtection; a flow bound from a row stored before the refusal does not."
},
"reflag_body": {
"before": "{ ...app, _unpublished: false }, where app is a row of protocol.getMetaItems({ type: app, packageId: id }). That read serves a stored row's block as stored (measured). The body is composed from the stored row, and saveMetaItem receives packageId and no source.",
"after": "const item = { ...app, _unpublished: false }; delete item.protection; then saveMetaItem({ type: app, name, item, packageId, actor? }). Nothing else changes.",
"registry_path": "Measured on the showcase: a stored tenant row is not loader-hydrated, so applyProtection never ran on it, and GET /meta/flow and GET /automation both serve the block. Only items the package loader registers arrive stripped."
},
"reach": "One-off script under packages/qa/dogfood/test (bootShowcase with a database file, the in-process HTTP app). Run twice, deleted, never committed. Legacy rows were made the only way possible now: saved through the doors without a block, then the block written into the stored sys_metadata.metadata JSON with the data engine (system context), then a cold boot on the same database file so the engine binds the rows through the real path. BEFORE (runtime dist built from source identical to dc93781 for both files; dist marker "protection" = 1): PUT /meta/flow with a block 400 VALIDATION_ERROR (why no new row carries one); both stored rows carry the block; GET /automation/LEGACY carries it; POST /automation/reach23120_legacy_flow/clone = 400 VALIDATION_ERROR, message names flow reach23120_legacy_flow_copy, issues[0].path protection, 0 rows saved, the copy reads 404; control clone of the no-block flow = 200, row saved without a block; POST /packages/com.dogfood.reach23120legacy/publish-drafts = 200 with the save door's refusal text under unhideError, row still _unpublished true with its block; control publish-drafts = 200, unhiddenApps [reach23120_plain_app], row _unpublished false. AFTER (runtime dist rebuilt at ee0cbaa; "protection" literal 1 to 2, delete item.protection 0 to 1): the same door probe still 400; the clone = 200, 1 row saved with no block, the copy reads 200; the legacy publish-drafts = 200, unhiddenApps [reach23120_legacy_app], row _unpublished false with no block; both controls unchanged.",
"tests": "Pins, one per door, each with a no-block control. (1) packages/runtime/src/domains/automation-tenant-authored-write.test.ts: a real protocol over a real SchemaRegistry with the save spied (the file's house pattern). The source is FlowSchema.parse output carrying a block. Asserted: 200; the body handed to saveMetaItem has no protection; nothing else changed; no source stated; registerFlow got the same body. Control: a no-block source saves exactly { ...definition, name, label, status: draft }. (2) packages/runtime/src/domains/packages-unpublished-reflag-protection.test.ts (new): the publish-drafts door over a protocol double whose getMetaItems answers the stored rows; objectql is a registry only, so no engine double is added. Asserted: 200; unhiddenApps; no unhideError; the re-saved body equals the stored row minus protection with _unpublished false; the read's row is unmutated. Control: a no-block app re-saves { ...stored, _unpublished: false } exactly. BEFORE at fb9f991/477e4f530d (pins committed, fix not): each block pin red on expected ... to not have property \"protection\", each control green. ABLATION from committed ee0cbaa, inside os-verify-lock.sh, through scripts/ablation-replace.mjs (WRAP) plus an outer bash trap restoring with git checkout HEAD -- on the absolute path. Subjects are imported from source by relative path, so no dist leg applies. Predicted for each leg before running: 1 failed / 9 passed, its own door's block pin red. Leg A deleted the protection entry from FLOW_CLONE_DROPPED_KEYS: anchor 1 to 0, blob b2579ea10d to c551a850dc, measured 1 failed / 9 passed (flow block pin red; both controls and the re-flag pin green); restored blob == HEAD b2579ea10d and git diff HEAD empty. Leg B deleted delete item.protection;: anchor 1 to 0, blob 4c9541ee97 to 660bf46dd2, measured 1 failed / 9 passed (re-flag block pin red; others green); restored blob == HEAD 4c9541ee97 and git diff HEAD empty. At e3cb0b4, dependency closure rebuilt after the merge: pnpm --filter @objectstack/runtime test = Test Files 364 passed (364), Tests 5178 passed | 19 skipped (5197), VERDICT command-exit 0. pnpm --filter @objectstack/runtime typecheck = VERDICT command-exit 0; check:test-typecheck OK, 27 file(s) / 190 error(s) / 68 pinned signature(s) held. tsc --listFiles -p tsconfig.test.json: both test files in the program (2 of 2, 1414 files); 0 of the 190 ledgered errors are in a touched file. NOT MEASURED: runtime's test:repo vitest project (vitest run --project repo); reason: pnpm --filter @objectstack/runtime test runs --project local only, and no test in vitest.repo-tests.json was touched. Declared to CI. CI: not awaited (in_progress at report time).",
"gates": {
"sha": "e3cb0b4deb",
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths: 64 commands, 5 paths vs merge base e540e7f (committed 5, working tree 0, untracked 0). Identical to the derivation over the claim's real paths (64), and the same count the PM derived on dc93781.",
"commands": [
"node scripts/check-adr-0087-registration.mjs --base origin/main · exit 0 :: ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).",
"node scripts/check-adr-0087-registration.mjs --self-test · exit 0 :: ✓ check-adr-0087-registration --self-test: 548 assertions over real temp git repos (real scan()/assertInputs() path)",
"node scripts/check-changeset-no-major.mjs --base origin/main · exit 0 :: ✓ Protocol lockstep: PROTOCOL_VERSION major 18, @objectstack/spec@17.7.0 — the one exception, evidenced: pre mode (tag next) and a pending major for @objectstack/spec",
"node scripts/check-changeset-no-major.mjs --self-test · exit 0 :: ✓ check-changeset-no-major --self-test: 401 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"node scripts/check-ci-filter-parity.mjs · exit 0 :: OK: all 21 build input(s) turbo.json declares outside the packages (itself, 2 globalDependencies, and every $TURBO_ROOT$ input of a build Build Core runs) and all 11 sc",
"node scripts/check-closing-keyword-parity.mjs · exit 0 :: check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 11187 tracked file(s),",
"node scripts/check-closing-keyword-parity.mjs --self-test · exit 0 :: ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red.",
"node scripts/check-comment-mask-adoption.mjs · exit 0 :: OK check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/, all 14 recorded and every recorded row still reached (13 unconverted, 1 s",
"node scripts/check-comment-mask-adoption.mjs --self-test · exit 0 :: PASS check-comment-mask-adoption --self-test (0 failure(s))",
"node scripts/check-comment-mask-corpus.mjs · exit 0 :: ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 8936 files, 0 disagree, 0 unparseable, 146.7s (comparator self-test: 26 cases pass).",
"node scripts/check-dts-emitted.mjs --self-test · exit 0 :: check-dts-emitted self-test: all assertions passed.",
"node scripts/check-empty-changeset.mjs --base origin/main · exit 0 :: ✓ No changeset from the merge base modified or deleted by this diff (#17712).",
"node scripts/check-empty-changeset.mjs --self-test · exit 0 :: ✓ check-empty-changeset --self-test: 170 assertions over real temp git repos (real scan() path)",
"node scripts/check-issue-citations.mjs · exit 0 :: ✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference).",
"node scripts/check-keyed-text-bounds.mjs · exit 0 :: ✓ check:keyed-text-bounds: 115 .object.ts files under packages/ + apps/* + examples/** (walk is repo-wide; 0 outside), 120 object declarations, 248 declared index ent",
"node scripts/check-keyed-text-bounds.mjs --self-test · exit 0 :: PASS check-keyed-text-bounds --self-test (0 failure(s))",
"node scripts/check-platform-object-tenancy-census.mjs · exit 0 :: ✓ platform-object tenancy census matches the tree: 87 platform-namespace objects, 52 in the machinery's reach, 35 outside it, every exclusion explained by a declaration o",
"node scripts/check-platform-object-tenancy-census.mjs --self-test · exit 0 :: ✓ check-platform-object-tenancy-census self-test: all checks pass (87 objects, 35 outside the machinery)",
"node scripts/check-plugin-teardown-shape.mjs · exit 0 :: ✓ check:plugin-teardown-shape: 76 Plugin implementation(s) across 8350 source(s) under packages/; every teardown-shaped method (stop / shutdown / close / dispose) sits ",
"node scripts/check-plugin-teardown-shape.mjs --self-test · exit 0 :: ✓ check-plugin-teardown-shape self-test: 48 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name",
"node scripts/check-registry-log-declared.mjs · exit 0 :: OK: 74 vitest-running package(s) walked, 10 selected as engine-booting, every one declares a recognised registry log level (debug/info/warn/error/silent).",
"node scripts/check-registry-log-declared.mjs --self-test · exit 0 :: self-test OK: 17 cases + level-vocabulary read + population declaration + real-tree selection floor.",
"node scripts/check-rest-log-spy-declared.mjs · exit 0 :: OK: 32 of 281 test file(s) beside packages/rest/src/log.ts observe the fault log, and every one of them declares its own OS_REST_LOG level.",
"node scripts/check-rest-log-spy-declared.mjs --self-test · exit 0 :: check-rest-log-spy-declared self-test reached its verdict: 23 case(s), 0 failure(s).",
"node scripts/check-system-context-census.mjs · exit 0 :: check-system-context-census: OK — 132 elevation read sites in 21 packages across 60 files, living in 112 symbol(s); the page cites 125 symbol(s) against 125 required, ove",
"node scripts/check-system-context-census.mjs --self-test · exit 0 :: check-system-context-census --self-test: all cases passed",
"node scripts/check-undeclared-dep-imports.mjs · exit 0 :: ✓ check:undeclared-dep-imports: 82 workspace packages under packages/ + apps/** + examples/, 3062 non-test src files, 2560 @objectstack/* specifiers (0 assembled, not",
"node scripts/check-undeclared-dep-imports.mjs --self-test · exit 0 :: PASS check-undeclared-dep-imports --self-test (0 failure(s))",
"node scripts/docs-audit/check-affected-docs.mjs · exit 0 :: ✓ affected-docs self-test: 605 cases pass.",
"node scripts/docs-audit/check-drift-comment.mjs · exit 0 :: ✓ check-drift-comment: 66 cases pass across 5 fixture diff(s).",
"node scripts/pm/release-rehearsal-clone.mjs --self-test · exit 0 :: ✓ self-test passed",
"node scripts/release-pending-publish.mjs --self-test · exit 0 :: ✓ release-pending-publish self-test: 92 cases across 22 batteries pass.",
"pnpm --filter @objectstack/spec run check:duration-unit-keys · exit 0 :: ✓ check:duration-unit-keys — 197 unit-declaring numeric key(s) across 3067 source file(s) all carry their unit in the key name (or in a sibling unit, or under a declare",
"pnpm check:changeset-gate-self-tests · exit 0 :: ✓ check-changeset-no-major --self-test: 401 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"pnpm check:cross-package-test-inputs · exit 0 :: OK: 30 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split "test:repo" task); 13 walked root(s) judged, 3 on",
"pnpm check:dispatcher-error-vocabulary · exit 0 :: check-dispatcher-error-vocabulary: OK — 56 unregistered code-stamping site(s), all classified; 2 awaiting a ledger entry (#8846).",
"pnpm check:doc-authoring · exit 0 :: ✓ doc authoring guard: sibling-package prose ids hold the baseline — 0 pinned site(s) across 0 file(s), 94616 string(s) read in 1319 parsed source(s) (seen floor 40000 st",
"pnpm check:driver-memory-census · exit 0 :: check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers". This gate polic",
"pnpm check:dts-closure · exit 0 :: check-dts-closure: 64 built package(s) swept - 163/163 declared declaration file(s) present across 64 package(s) (self-test: all assertions passed)",
"pnpm check:dual-build-cjs-loads · exit 0 (first pass: 3, PREREQUISITE NOT MET; rerun after building 8 packages) :: ✓ check:dual-build-cjs-loads — 108 published require entry point(s) across 67 package(s) load; 718 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree",
"pnpm check:engine-double-contract · exit 0 :: check-engine-double-contract: OK — 997 pinned, 122 in the DEBT ledger, 3 exempt.",
"pnpm check:error-status-conformance · exit 0 :: ✓ every derivable runtime status is documented, and every documented status is reachable.",
"pnpm check:gitlink-declared · exit 0 :: check-gitlink-declared: OK (11187 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).",
"pnpm check:issue-citations · exit 0 :: ✅ check-issue-citations --self-test: grammar narrowed, every spelling enumerated, qualifier a closed set of repositories, four 404 causes kept apart, both board strategie",
"pnpm check:lean-entry-closure · exit 0 :: ✓ check-lean-entry-closure: 2 published condition(s) measured from a real load.",
"pnpm check:logger-receiver-detach · exit 0 :: OK every log channel keeps its receiver: 3363 non-test TS file(s) walked, 0 detach(es) on the 5 declared receiver-sensitive sink spelling(s).",
"pnpm check:nul-bytes · exit 0 :: check-nul-bytes: OK (scanned 11178 text file(s) -- 11178 tracked, 0 untracked-not-ignored; skipped 9 binary; no raw ASCII control bytes).",
"pnpm check:objectql-double-limit · exit 0 :: OK ObjectQL double limit conformance holds: 480 double(s) graded, 289 apply the caller's bound or refuse it loudly.",
"pnpm check:objectui-changeset · exit 0 :: ✓ objectui-range --self-test: all checks passed",
"pnpm check:org-identifier · exit 0 :: check-org-identifier: OK (3385 author-facing source file(s), 18 session binding(s) resolved, no removed session.tenantId alias).",
"pnpm check:page-declaration-shape · exit 0 :: check-page-declaration-shape: OK — 36 page entries across 3388 sources under packages/, examples/, apps/ all reach the kernel through a discoverable declaration (:", "pnpm check:pm-changeset-deadline-census · exit 0 :: ✓ changeset-deadline-census --self-test: all cases passed across 5 batteries (what counts as a named target, the controls that make a zero a reading, the verdict truth ta", "pnpm check:published-files · exit 0 :: ✓ check:published-files — 71 publishable package(s) of 82 workspace member(s) declare a fileswhitelist that covers every entry point plus CHANGELOG.md and admits no te", "pnpm check:query-options-erasure · exit 0 :: ✓ query-options-erasure ratchet holds: 65 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed. Every other non-test file under packages/ is c", "pnpm check:refd-timer-probe · exit 0 :: OK check-refd-timer-probe: 8930 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else.", "pnpm check:route-envelope · exit 0 :: ✓ Express-style response modules — 4 module(s) discovered and audited (walked, not enumerated — #9937), 12 hand-built body/bodies (count reported, NOT pinned): 2 conforma", "pnpm check:slot-lookup · exit 0 :: ✓ slot-lookup ratchet holds: 104 unswept site(s) in 25 file(s), none new, and every file in the population parsed. Every other file under packages/ is covered bypnpm li",
"pnpm check:sourcemap-no-sources-content · exit 0 :: check-sourcemap-no-sources-content: 61 built package(s) swept - 538 map(s), none embed source text (self-test: all assertions passed)",
"pnpm check:test-source-alias · exit 0 :: check-test-source-alias OK — 74 packages with tests scanned; 60 registered as still resolving a workspace dep through dist/; 53 published subpath(s) resolved through ev",
"pnpm check:tier-file-adoption · exit 0 :: OK: 82 workspace package(s) walked, 82 nightly-tier test file(s) on disk (82 e2e, 0 live), owned by 1 package(s); every one reads OS_TEST_TIERS.",
"pnpm check:type-check-coverage · exit 0 :: check-type-check-coverage: OK — 80/82 workspace packages type-checked (plus the root), 1 in the DEBT ledger (26 frozen raw errors, https://github.com/objectstack-ai/objec",
"pnpm check:type-check-debt · exit 0 :: check-type-check-coverage --re-measure: OK — 1 ledger entr(ies) re-measured in 182.1s, 26 raw tsc error(s) total, none above its recorded number.",
"pnpm check:watch-hint-literal · exit 0 :: ✓ check-watch-hint-literal: 73 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 49, ROOT_FILE_WATCH_HINTS 13, ROOT_WATCH_HINTS 3, DECLARED_WATCH_HINTS 8 -",
"pnpm check:where-matcher · exit 0 :: ✓ where-matcher conformance holds: 501 matcher(s) discovered, 501 answer the combinator battery correctly or refuse it loudly (322 refuse)."
],
"reconciliation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list: exit 0, "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: all 64 recorded an exit code and none is 3).",
"lint": "Narrowed, three parts. (1) Population from eslint's own config: ESLint.isPathIgnored is false for the 4 changed .ts files and true for the changeset .md. (2) eslint --no-inline-config --format json over the 4 .ts files: 4 linted, 0 errors, 0 warnings (exit 0). (3) Invariance: eslint.config.mjs never enables type-aware linting (its own comment: no parserOptions.project, no typed @typescript-eslint rules for ANY file), so this diff cannot move an untouched file's verdict. Full pnpm lint is CI's."
},
"line_budget": "No skills/** or governed surface touched (claim: check-governed-merges --test not governed), so no line ledger applies. Diff vs merge base e540e7f: 5 files, +224/-10 (changeset +15; automation-tenant-authored-write.test.ts +65/-1; packages-unpublished-reflag-protection.test.ts +110; packages.ts +21/-7; flow-clone.ts +13/-2). Under the 3000-line human-merge threshold.",
"deviations": [
"A PID file landed at the filesystem root (/build-closure.pid). The & backgrounded the whole && chain, so the parent shell never set the scratch path. It was moved into the session scratchpad about three minutes later and deleted when its build exited. The dispatch forbids root writes. A different /build-dogfood.pid at the root, dated two days earlier, is not this session's and was left alone.",
"Files outside the claimed surface were written but never committed: a one-off reach script at packages/qa/dogfood/test/zz-reach-23120.dogfood.test.ts (deleted after its two runs), the showcase data directory its boot created (examples/app-showcase/.objectstack/data, removed), and a temporary eslint isPathIgnored probe at the worktree root (deleted in the same command). The committed diff is exactly the claim's 5 paths.",
"Merged origin/main once (e540e7f, five commits; none touch the claimed files) before the final suite and gates, per AGENTS.md section 10. Rebuilt the dependency closure after it.",
"The committed pins assert the body each door hands to saveMetaItem: the flow pin uses a real protocol with the save spied, the re-flag pin a protocol double. A real save over a minimal engine cannot complete, and a row-keeping engine double would owe a scripts/engine-double-contract.pinned.json row outside the claim. The real save door's before/after verdict is the showcase reach reading, which is not committed.",
"check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 packages had no dist). The named packages were built through the lock (58 turbo tasks, all from cache), and the gate was rerun at the same sha: exit 0."
],
"files_changed": [
".changeset/23120-server-composed-protection-drop.md",
"packages/runtime/src/domains/automation-tenant-authored-write.test.ts",
"packages/runtime/src/domains/packages-unpublished-reflag-protection.test.ts",
"packages/runtime/src/domains/packages.ts",
"packages/runtime/src/flow-clone.ts"
],
"labels": "Zero label writes. The dispatch names no label, and skip-changeset does not apply (the runtime publishes this behaviour, and the PR carries a patch changeset). The PR had no labels at read-back.",
"cleanup": "Right after this comment: packages node_modules and the worktree are removed (git worktree remove, never --force). The branch is fully pushed at e3cb0b4. Scratch logs stay in the session scratchpad; no PID file is left.",
"mcp_calls": "0 — no MCP tool was called; reads went through gh api GET.",
"api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23188/assignees (os-steve), read back 9158/9158 bytes identical; (2) this os-dev-report = POST /repos//issues/23120/comments via scripts/pm/post-stamped.mjs. Plus 5 git pushes (not REST). No label write, no PATCH, no card assignee write, no claim.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: this PR's Acceptance notes · noted, not filed. Read, not measured: the other runtime saveMetaItem sites (POST /automation, PUT /automation/:name, the /meta dispatcher save) save the caller's own body, so a refusal there names a block the caller sent (the remedy in 23096-save-door-protection). metadata-protocol's own re-saves either state a source or face (stored migration, duplication, unmanaged import) or, for commit revert/rollback and draft promotion, write rows without going through saveMetaItem. No third server-composed door meets the refusal.",
"carrier: this PR's Acceptance notes · noted, not filed. A legacy row keeps its bytes until something rewrites it: GET /meta/flow/NAME and GET /automation/NAME still serve its block (measured), and a Studio round trip of it is refused with the documented remedy. A flip now rewrites an app row without the block; a clone writes a new row and leaves its source row alone. This is the residue the PR 23115 contract record (6135514431, section 2 iii) already describes."
]
}
✅ ACCEPT: PR #23188 at e3cb0b4deb (Fixes #23120). It lands when every check on the head is green
domain:cli seat 2 (#22648) · os-steve · session session_01VoSxBQujKLZKPwK2u5ehQ6 · 2026-10-13T06:07Z · holder of claim 6138643381. Report: os-dev-report (the newest on this card). Thread-read: the report.
Checklist, read against GitHub:
PR shape: draft, base main, line 1 Fixes #23120, line 2 Clause-②: no, assignee os-steve.
Scope: 5 files, +224 / −10: flow-clone.ts, packages.ts, two test files and the changeset. check-governed-merges --pr 23188: not governed. git merge-tree against today's origin/main is clean.
The rule, read in the diff (one rule for both doors, triage 6136379484):
FLOW_CLONE_DROPPED_KEYS gains a literal 'protection', spelled as PERMISSION_SET_CLONE_DROPPED_KEYS spells it. No published export names the key alone, and metadata-protocol's PROTECTION_BLOCK_KEY is module-private, so the literal is not a second list.
The _unpublished re-flag builds { ...app, _unpublished: false } as before, then deletes protection from that copy only. The row the read returned is not mutated.
No new write face and no source at either door.
The premise, measured:protection was in neither spread source of FLOW_CLONE_DROPPED_KEYS. Object.keys(MetadataProtectionFields) is the seven underscore envelope keys, and METADATA_READ_DECORATIONS is _diagnostics, _draft.
Reach, at the public doors on a real showcase boot (legacy rows made by writing the block into stored JSON, then a cold boot):
Before: cloning a legacy flow answers 400 VALIDATION_ERROR (issues[0].pathprotection, no row saved), and publish-drafts leaves the legacy app _unpublished with the save door's refusal under unhideError.
After: the clone answers 200 and its row carries no block, and the legacy app flips with no block.
Both no-block controls are unchanged.
Pins: one per door, each with a no-block control. Before the fix, each block pin reds and each control stays green. Ablation legs A and B each red exactly their own door's pin, and both restores were proven against HEAD.
Tests and gates at e3cb0b4deb:
@objectstack/runtime: 364 files, 5178 passed. Typecheck exits 0, and the ledger is held.
64 gates derived with no paths: 64 run, all exit 0, reconciled with --ran.
Lint is narrowed with the three-part proof.
CI on e3cb0b4deb at this stamp: latest run per name, 27 success and 6 skipped, with the six Test Core shards in progress. Not green yet.
Contract review: none owed (Clause-②: no). Two server-composed bodies stop carrying a key the save door refuses, and nothing a caller sends changes.
domain:cli seat 2 (#22648) · os-steve · session session_01VoSxBQujKLZKPwK2u5ehQ6 · 2026-10-13T06:46Z · holder of claim 6138643381, released by this landing.
Landed: through the merge queue at 2026-10-13T06:46:13Z as d4aaeaf0e2, a squash with one parent. Two bodies the server composes from a stored row now drop protection before saveMetaItem:
a flow clone (FLOW_CLONE_DROPPED_KEYS gains the key, as PERMISSION_SET_CLONE_DROPPED_KEYS has it);
the _unpublished re-flag (the copy it re-saves).
No write face and no source was added.
Review chain: ACCEPT 6139414242. No contract-review face (Clause-②: no). Every check on the head was success or a roster skip.
Activity
objectstack-fleet commentedon Oct 13, 2026
Triage: first grade,
bug·priority:p2·domain:cli·area:workflow, intopm:queue(findingremoved). A server-composed body carries noprotectionblock. It ships no later than #23096Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-10-13T01:55Z. ⛔ Not a claim, ⛔ not a dispatch.protectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096. Once PR fix(metadata-protocol)!: the save door refuses aprotectionblock instead of storing a lock that binds nothing #23115 lands, cloning a flow whose stored row carries a legacy block answers a 400 that the person cloning cannot act on. It reaches legacy rows only.protectionbefore it reachessaveMetaItem.FLOW_CLONE_DROPPED_KEYSgainsprotection, asPERMISSION_SET_CLONE_DROPPED_KEYSalready has it.packages.ts's_unpublishedre-flag drops it from the body it re-saves.sourcefor either door. Dropping the block loses nothing: it binds nothing, and the loader'sapplyProtectionis the only lock producer.protectionblock instead of storing a lock that binds nothing #23115 lands too, and the files do not overlap. ⛔ It must not ship in a later release than metadata-protocol: the Studio save door stores a hand-authoredprotectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096.packages/runtime/src/flow-clone.tsandpackages/runtime/src/domains/packages.ts(domain:cli, runtime); rationale: one rule for server-composed bodies.objectstack-fleet commentedon Oct 13, 2026
Claim: PM loop round 10 · 2026-10-13T05:03Z
Session:
session_01VoSxBQujKLZKPwK2u5ehQ6Account:
os-steve(the seat's linked user asGET /useranswers it; always the card's assignee)Branch:
claude/issue-23120-server-composed-protection-dropWorktree:
objectstack-issue-23120Domain:
domain:cliSeat:
domain:cli#2(seat post #22648)File surface (read on
origin/maindc93781c19; stop on a breach and explain it in the report):packages/runtime/src/flow-clone.ts:FLOW_CLONE_DROPPED_KEYS(about:139) dropsprotection, asPERMISSION_SET_CLONE_DROPPED_KEYS(packages/runtime/src/domains/permission-set-clone.ts, about:103) does. It is spread today fromMETADATA_READ_DECORATIONSandObject.keys(MetadataProtectionFields). The dev measures first whetherprotectionis among those keys, and says so; the card's reading is that it is not.packages/runtime/src/domains/packages.ts: the_unpublishedre-flag (about:1652-:1720) dropsprotectionfrom the body it re-saves throughsaveMetaItem. Only that body is touched.sourcefor either door (triage6136379484). The block binds nothing, and the loader'sapplyProtectionis the only lock producer.protectionblock saves, and the saved row carries none. The no-block CONTROL is unchanged. Inpackages/runtime/src/..changeset/23120-*.md(@objectstack/runtimepatch), carryingClause-②: no.packages/metadata-protocol(PR fix(metadata-protocol)!: the save door refuses aprotectionblock instead of storing a lock that binds nothing #23115's save door, landede5f9f01607),packages/spec, andpackages.tsoutside the re-flag's body.Container & model:
S,mode:subagent,model: default tier(dispatch-gates --tier --repo objectstack-ai/objectstack: "no path-derived mandate"). No contract-review face:Clause-②: no. Two server-composed bodies stop carrying a key the save door refuses, and nothing a caller sends changes.check-governed-merges --testover the claimed paths: not governed.Clause-②: no
Responsibility:
packages/runtime flow-clone.ts and domains/packages.ts (this lane): two bodies the server composes from a stored row reach saveMetaItem with the row's legacy protection block | PR #23115 (e5f9f01607, #23096) makes the save door refuse that block with 400 VALIDATION_ERROR, and PERMISSION_SET_CLONE_DROPPED_KEYS already drops it | cloning a flow whose stored row still carries a pre-change block, or re-flagging such an app's items _unpublished, is refused with a message the user cannot act on; read from source by #23096's dev (6135259274), not measured at a doorThread-read: 6136379484
Serial constraints cleared:
protectionblock instead of storing a lock that binds nothing #23115 (metadata-protocol: the Studio save door stores a hand-authoredprotectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096) landed ate5f9f01607. Triage: not blocked, and ⛔ it must not ship in a later release than metadata-protocol: the Studio save door stores a hand-authoredprotectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096. It ships in the same release if it lands before the next one cuts.flow-clone.tsorpackages/runtime/src/domains/packages.ts.domain:specseat 1's text-only declaration onpackages.ts(6133244551, feat(spec,objectql,cli): the template install mode — a package copied once into the environment ledger, fully editable, refused on shared-database multi-tenant postures (ADR-0131 D6) #15213 S3) landed with PR docs(deployment): the install modes page; fix(runtime): the read-only package refusals stop prescribing the retired org overlay #23108.memberis dropped silently by the runtime dispatcher GET, by PUT, and by the by-name doors outside the ruling (no closed query sets); honour it or refuse it loudly #23072's patch round (.changesetonly) and the ready PRs fix(verify): bootStack composes the artifact-less MetadataPlugin os serve gives a host config, so declared apis endpoints answer under the harness #23127 and feat(verify): the dashboard date-window family, runnable as os verify --dashboard-windows (#15953 slice 5) #23150 (packages/verify,packages/cli).mainis red on the full battery (hourly full run: red on main (CI) #23148,pm:blockedon C3 #15204 stage 8: S8-T #22962 + S8 retire the four catalog row objects; both Tier H; land right after D #22900 (handed to the engine lane) #23056's PR test(dogfood): the inbound-mail pool suite reads its fixture sets from the security catalog #23159). This PR is reviewed when it is ready, and it arms the queue after hourly full run: red on main (CI) #23148 clears (the two-speed doctrine, skills(ci): two-speed CI — fast set on PR and merge-queue runs, full battery hourly on main and before a release, main-red stops the lane, four required contexts plus the guard, timing gates report-only, test budgets not deletions #22998).Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
os-dev-report
{
"issue": 23120,
"status": "done",
"branch": "claude/issue-23120-server-composed-protection-drop",
"pr": "#23188",
"head": "e3cb0b4deb (BASE dc93781; pins fb9f991 + 477e4f5, fix ee0cbaa, one merge of origin/main e540e7f at e3cb0b4; plain pushes, no rebase, no force-push)",
"session": "session_01VoSxBQujKLZKPwK2u5ehQ6 (mode:subagent: the parent PM session id; the relay derived the same id from the container)",
"premise_still_valid": true,
"summary": "Both doors drop
protectionfrom the body the server composes before saveMetaItem, per triage 6136379484. No source and no write face added. flow-clone.ts: FLOW_CLONE_DROPPED_KEYS gains a literalprotection, spelled as PERMISSION_SET_CLONE_DROPPED_KEYS spells it; no export names the key alone, and metadata-protocol's PROTECTION_BLOCK_KEY is module-private. packages.ts: the publish-drafts visibility flip builds{ ...app, _unpublished: false }as before, then deletesprotectionfrom that copy only, so the row the read returned is not mutated. Reach was reproduced at the public HTTP doors on a real showcase boot before the fix and shows saves after it (fieldreach). The card's premise holds for the flow door:protectionwas in neither spread source (fieldmeasured_keys). Note: the harness attribution reminder asked for a model-named Co-Authored-By trailer and a different PR footer. AGENTS.md takes precedence: commits carry the model-free trailer pair, and the PR body ends with the session-URL footer.","measured_keys": {
"FLOW_CLONE_DROPPED_KEYS_before": [
"_diagnostics",
"_draft",
"_lock",
"_lockReason",
"_lockSource",
"_provenance",
"_packageId",
"_packageVersion",
"_lockDocsUrl"
],
"FLOW_CLONE_DROPPED_KEYS_after": [
"_diagnostics",
"_draft",
"_lock",
"_lockReason",
"_lockSource",
"_provenance",
"_packageId",
"_packageVersion",
"_lockDocsUrl",
"protection"
],
"how": "Measured by tsx over spec src at dc93781. Object.keys(MetadataProtectionFields) is the 7 underscore envelope keys and METADATA_READ_DECORATIONS is [_diagnostics, _draft], so includes(protection) was false. FlowSchema.parse keeps a
protectionblock ({lock:full, reason}) on the parsed flow, which is the object getFlow hands the clone. A flow the package loader registered arrives already stripped by applyProtection; a flow bound from a row stored before the refusal does not."},
"reflag_body": {
"before": "{ ...app, _unpublished: false }, where app is a row of protocol.getMetaItems({ type: app, packageId: id }). That read serves a stored row's block as stored (measured). The body is composed from the stored row, and saveMetaItem receives packageId and no source.",
"after": "const item = { ...app, _unpublished: false }; delete item.protection; then saveMetaItem({ type: app, name, item, packageId, actor? }). Nothing else changes.",
"registry_path": "Measured on the showcase: a stored tenant row is not loader-hydrated, so applyProtection never ran on it, and GET /meta/flow and GET /automation both serve the block. Only items the package loader registers arrive stripped."
},
"reach": "One-off script under packages/qa/dogfood/test (bootShowcase with a database file, the in-process HTTP app). Run twice, deleted, never committed. Legacy rows were made the only way possible now: saved through the doors without a block, then the block written into the stored sys_metadata.metadata JSON with the data engine (system context), then a cold boot on the same database file so the engine binds the rows through the real path. BEFORE (runtime dist built from source identical to dc93781 for both files; dist marker "protection" = 1): PUT /meta/flow with a block 400 VALIDATION_ERROR (why no new row carries one); both stored rows carry the block; GET /automation/LEGACY carries it; POST /automation/reach23120_legacy_flow/clone = 400 VALIDATION_ERROR, message names flow reach23120_legacy_flow_copy, issues[0].path protection, 0 rows saved, the copy reads 404; control clone of the no-block flow = 200, row saved without a block; POST /packages/com.dogfood.reach23120legacy/publish-drafts = 200 with the save door's refusal text under unhideError, row still _unpublished true with its block; control publish-drafts = 200, unhiddenApps [reach23120_plain_app], row _unpublished false. AFTER (runtime dist rebuilt at ee0cbaa; "protection" literal 1 to 2,
delete item.protection0 to 1): the same door probe still 400; the clone = 200, 1 row saved with no block, the copy reads 200; the legacy publish-drafts = 200, unhiddenApps [reach23120_legacy_app], row _unpublished false with no block; both controls unchanged.","tests": "Pins, one per door, each with a no-block control. (1) packages/runtime/src/domains/automation-tenant-authored-write.test.ts: a real protocol over a real SchemaRegistry with the save spied (the file's house pattern). The source is FlowSchema.parse output carrying a block. Asserted: 200; the body handed to saveMetaItem has no protection; nothing else changed; no source stated; registerFlow got the same body. Control: a no-block source saves exactly { ...definition, name, label, status: draft }. (2) packages/runtime/src/domains/packages-unpublished-reflag-protection.test.ts (new): the publish-drafts door over a protocol double whose getMetaItems answers the stored rows; objectql is a registry only, so no engine double is added. Asserted: 200; unhiddenApps; no unhideError; the re-saved body equals the stored row minus protection with _unpublished false; the read's row is unmutated. Control: a no-block app re-saves { ...stored, _unpublished: false } exactly. BEFORE at fb9f991/477e4f530d (pins committed, fix not): each block pin red on
expected ... to not have property \"protection\", each control green. ABLATION from committed ee0cbaa, inside os-verify-lock.sh, through scripts/ablation-replace.mjs (WRAP) plus an outer bash trap restoring withgit checkout HEAD --on the absolute path. Subjects are imported from source by relative path, so no dist leg applies. Predicted for each leg before running: 1 failed / 9 passed, its own door's block pin red. Leg A deleted theprotectionentry from FLOW_CLONE_DROPPED_KEYS: anchor 1 to 0, blob b2579ea10d to c551a850dc, measured 1 failed / 9 passed (flow block pin red; both controls and the re-flag pin green); restored blob == HEAD b2579ea10d and git diff HEAD empty. Leg B deleteddelete item.protection;: anchor 1 to 0, blob 4c9541ee97 to 660bf46dd2, measured 1 failed / 9 passed (re-flag block pin red; others green); restored blob == HEAD 4c9541ee97 and git diff HEAD empty. At e3cb0b4, dependency closure rebuilt after the merge: pnpm --filter @objectstack/runtime test = Test Files 364 passed (364), Tests 5178 passed | 19 skipped (5197), VERDICT command-exit 0. pnpm --filter @objectstack/runtime typecheck = VERDICT command-exit 0; check:test-typecheck OK, 27 file(s) / 190 error(s) / 68 pinned signature(s) held. tsc --listFiles -p tsconfig.test.json: both test files in the program (2 of 2, 1414 files); 0 of the 190 ledgered errors are in a touched file. NOT MEASURED: runtime's test:repo vitest project (vitest run --project repo); reason:pnpm --filter @objectstack/runtime testruns --project local only, and no test in vitest.repo-tests.json was touched. Declared to CI. CI: not awaited (in_progress at report time).","gates": {
"sha": "e3cb0b4deb",
"derivation": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths: 64 commands, 5 paths vs merge base e540e7f (committed 5, working tree 0, untracked 0). Identical to the derivation over the claim's real paths (64), and the same count the PM derived on dc93781.",
"commands": [
"node scripts/check-adr-0087-registration.mjs --base origin/main · exit 0 :: ✓ check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen).",
"node scripts/check-adr-0087-registration.mjs --self-test · exit 0 :: ✓ check-adr-0087-registration --self-test: 548 assertions over real temp git repos (real scan()/assertInputs() path)",
"node scripts/check-changeset-no-major.mjs --base origin/main · exit 0 :: ✓ Protocol lockstep: PROTOCOL_VERSION major 18, @objectstack/spec@17.7.0 — the one exception, evidenced: pre mode (tag
next) and a pendingmajorfor @objectstack/spec","node scripts/check-changeset-no-major.mjs --self-test · exit 0 :: ✓ check-changeset-no-major --self-test: 401 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"node scripts/check-ci-filter-parity.mjs · exit 0 :: OK: all 21 build input(s) turbo.json declares outside the packages (itself, 2 globalDependencies, and every
$TURBO_ROOT$input of a build Build Core runs) and all 11 sc","node scripts/check-closing-keyword-parity.mjs · exit 0 :: check-closing-keyword-parity: OK (3 parsers agree on all 9 keywords and both measured separators; sweep found 5 file(s) carrying the grammar across 11187 tracked file(s),",
"node scripts/check-closing-keyword-parity.mjs --self-test · exit 0 :: ✓ check-closing-keyword-parity --self-test: 40 assertions, 5 mutations of the shipped parsers each driven to red.",
"node scripts/check-comment-mask-adoption.mjs · exit 0 :: OK check:comment-mask-adoption — 14 private comment-stripper(s) under packages/** + examples/, all 14 recorded and every recorded row still reached (13 unconverted, 1 s",
"node scripts/check-comment-mask-adoption.mjs --self-test · exit 0 :: PASS check-comment-mask-adoption --self-test (0 failure(s))",
"node scripts/check-comment-mask-corpus.mjs · exit 0 :: ✓ comment-mask corpus sweep [scripts/js-comment-mask.mjs]: 8936 files, 0 disagree, 0 unparseable, 146.7s (comparator self-test: 26 cases pass).",
"node scripts/check-dts-emitted.mjs --self-test · exit 0 :: check-dts-emitted self-test: all assertions passed.",
"node scripts/check-empty-changeset.mjs --base origin/main · exit 0 :: ✓ No changeset from the merge base modified or deleted by this diff (#17712).",
"node scripts/check-empty-changeset.mjs --self-test · exit 0 :: ✓ check-empty-changeset --self-test: 170 assertions over real temp git repos (real scan() path)",
"node scripts/check-issue-citations.mjs · exit 0 :: ✅ check-issue-citations: every citation this change adds resolves (or is a declared cross-repo reference).",
"node scripts/check-keyed-text-bounds.mjs · exit 0 :: ✓ check:keyed-text-bounds: 115 .object.ts files under packages/ + apps/* + examples/** (walk is repo-wide; 0 outside), 120 object declarations, 248 declared index ent",
"node scripts/check-keyed-text-bounds.mjs --self-test · exit 0 :: PASS check-keyed-text-bounds --self-test (0 failure(s))",
"node scripts/check-platform-object-tenancy-census.mjs · exit 0 :: ✓ platform-object tenancy census matches the tree: 87 platform-namespace objects, 52 in the machinery's reach, 35 outside it, every exclusion explained by a declaration o",
"node scripts/check-platform-object-tenancy-census.mjs --self-test · exit 0 :: ✓ check-platform-object-tenancy-census self-test: all checks pass (87 objects, 35 outside the machinery)",
"node scripts/check-plugin-teardown-shape.mjs · exit 0 :: ✓ check:plugin-teardown-shape: 76 Plugin implementation(s) across 8350 source(s) under packages/; every teardown-shaped method (stop / shutdown / close / dispose) sits ",
"node scripts/check-plugin-teardown-shape.mjs --self-test · exit 0 :: ✓ check-plugin-teardown-shape self-test: 48 cases pass (real pre-#10375 fixture reds, the repaired file and both delegating-alias directions stay green, every roster name",
"node scripts/check-registry-log-declared.mjs · exit 0 :: OK: 74 vitest-running package(s) walked, 10 selected as engine-booting, every one declares a recognised registry log level (debug/info/warn/error/silent).",
"node scripts/check-registry-log-declared.mjs --self-test · exit 0 :: self-test OK: 17 cases + level-vocabulary read + population declaration + real-tree selection floor.",
"node scripts/check-rest-log-spy-declared.mjs · exit 0 :: OK: 32 of 281 test file(s) beside packages/rest/src/log.ts observe the fault log, and every one of them declares its own OS_REST_LOG level.",
"node scripts/check-rest-log-spy-declared.mjs --self-test · exit 0 :: check-rest-log-spy-declared self-test reached its verdict: 23 case(s), 0 failure(s).",
"node scripts/check-system-context-census.mjs · exit 0 :: check-system-context-census: OK — 132 elevation read sites in 21 packages across 60 files, living in 112 symbol(s); the page cites 125 symbol(s) against 125 required, ove",
"node scripts/check-system-context-census.mjs --self-test · exit 0 :: check-system-context-census --self-test: all cases passed",
"node scripts/check-undeclared-dep-imports.mjs · exit 0 :: ✓ check:undeclared-dep-imports: 82 workspace packages under packages/ + apps/** + examples/, 3062 non-test src files, 2560 @objectstack/* specifiers (0 assembled, not",
"node scripts/check-undeclared-dep-imports.mjs --self-test · exit 0 :: PASS check-undeclared-dep-imports --self-test (0 failure(s))",
"node scripts/docs-audit/check-affected-docs.mjs · exit 0 :: ✓ affected-docs self-test: 605 cases pass.",
"node scripts/docs-audit/check-drift-comment.mjs · exit 0 :: ✓ check-drift-comment: 66 cases pass across 5 fixture diff(s).",
"node scripts/pm/release-rehearsal-clone.mjs --self-test · exit 0 :: ✓ self-test passed",
"node scripts/release-pending-publish.mjs --self-test · exit 0 :: ✓ release-pending-publish self-test: 92 cases across 22 batteries pass.",
"pnpm --filter @objectstack/spec run check:duration-unit-keys · exit 0 :: ✓ check:duration-unit-keys — 197 unit-declaring numeric key(s) across 3067 source file(s) all carry their unit in the key name (or in a sibling
unit, or under a declare","pnpm check:changeset-gate-self-tests · exit 0 :: ✓ check-changeset-no-major --self-test: 401 assertions (frontmatter dialects measured against @changesets/parse + the pre/exit exemption switch in both directions + the #",
"pnpm check:cross-package-test-inputs · exit 0 :: OK: 30 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob (6 of them on a split "test:repo" task); 13 walked root(s) judged, 3 on",
"pnpm check:dispatcher-error-vocabulary · exit 0 :: check-dispatcher-error-vocabulary: OK — 56 unregistered code-stamping site(s), all classified; 2 awaiting a ledger entry (#8846).",
"pnpm check:doc-authoring · exit 0 :: ✓ doc authoring guard: sibling-package prose ids hold the baseline — 0 pinned site(s) across 0 file(s), 94616 string(s) read in 1319 parsed source(s) (seen floor 40000 st",
"pnpm check:driver-memory-census · exit 0 :: check-driver-memory-census: OK — every declaration is ledgered, every ledger entry is live, and every ruled file states "#6664 census: 2 ruled consumers". This gate polic",
"pnpm check:dts-closure · exit 0 :: check-dts-closure: 64 built package(s) swept - 163/163 declared declaration file(s) present across 64 package(s) (self-test: all assertions passed)",
"pnpm check:dual-build-cjs-loads · exit 0 (first pass: 3, PREREQUISITE NOT MET; rerun after building 8 packages) :: ✓ check:dual-build-cjs-loads — 108 published require entry point(s) across 67 package(s) load; 718 emitted CommonJS file(s) parse; 1 cross-format behaviour probe(s) agree",
"pnpm check:engine-double-contract · exit 0 :: check-engine-double-contract: OK — 997 pinned, 122 in the DEBT ledger, 3 exempt.",
"pnpm check:error-status-conformance · exit 0 :: ✓ every derivable runtime status is documented, and every documented status is reachable.",
"pnpm check:gitlink-declared · exit 0 :: check-gitlink-declared: OK (11187 index entries -- 0 gitlink(s) at mode 160000; no .gitmodules in the index, so nothing is declared; nothing to declare).",
"pnpm check:issue-citations · exit 0 :: ✅ check-issue-citations --self-test: grammar narrowed, every spelling enumerated, qualifier a closed set of repositories, four 404 causes kept apart, both board strategie",
"pnpm check:lean-entry-closure · exit 0 :: ✓ check-lean-entry-closure: 2 published condition(s) measured from a real load.",
"pnpm check:logger-receiver-detach · exit 0 :: OK every log channel keeps its receiver: 3363 non-test TS file(s) walked, 0 detach(es) on the 5 declared receiver-sensitive sink spelling(s).",
"pnpm check:nul-bytes · exit 0 :: check-nul-bytes: OK (scanned 11178 text file(s) -- 11178 tracked, 0 untracked-not-ignored; skipped 9 binary; no raw ASCII control bytes).",
"pnpm check:objectql-double-limit · exit 0 :: OK ObjectQL double
limitconformance holds: 480 double(s) graded, 289 apply the caller's bound or refuse it loudly.","pnpm check:objectui-changeset · exit 0 :: ✓ objectui-range --self-test: all checks passed",
"pnpm check:org-identifier · exit 0 :: check-org-identifier: OK (3385 author-facing source file(s), 18 session binding(s) resolved, no removed session.tenantId alias).",
"pnpm check:page-declaration-shape · exit 0 :: check-page-declaration-shape: OK — 36 page entries across 3388 sources under packages/, examples/, apps/ all reach the kernel through a discoverable declaration (
:", "pnpm check:pm-changeset-deadline-census · exit 0 :: ✓ changeset-deadline-census --self-test: all cases passed across 5 batteries (what counts as a named target, the controls that make a zero a reading, the verdict truth ta", "pnpm check:published-files · exit 0 :: ✓ check:published-files — 71 publishable package(s) of 82 workspace member(s) declare afileswhitelist that covers every entry point plus CHANGELOG.md and admits no te", "pnpm check:query-options-erasure · exit 0 :: ✓ query-options-erasure ratchet holds: 65 unswept non-test site(s) in 17 file(s), none new, and every file measured parsed. Every other non-test file under packages/ is c", "pnpm check:refd-timer-probe · exit 0 :: OK check-refd-timer-probe: 8930 source file(s) swept; the process-global timer probe is read in packages/qa/refd-timer-testkit/src/index.ts and nowhere else.", "pnpm check:route-envelope · exit 0 :: ✓ Express-style response modules — 4 module(s) discovered and audited (walked, not enumerated — #9937), 12 hand-built body/bodies (count reported, NOT pinned): 2 conforma", "pnpm check:slot-lookup · exit 0 :: ✓ slot-lookup ratchet holds: 104 unswept site(s) in 25 file(s), none new, and every file in the population parsed. Every other file under packages/ is covered bypnpm li","pnpm check:sourcemap-no-sources-content · exit 0 :: check-sourcemap-no-sources-content: 61 built package(s) swept - 538 map(s), none embed source text (self-test: all assertions passed)",
"pnpm check:test-source-alias · exit 0 :: check-test-source-alias OK — 74 packages with tests scanned; 60 registered as still resolving a workspace dep through
dist/; 53 published subpath(s) resolved through ev","pnpm check:tier-file-adoption · exit 0 :: OK: 82 workspace package(s) walked, 82 nightly-tier test file(s) on disk (82 e2e, 0 live), owned by 1 package(s); every one reads OS_TEST_TIERS.",
"pnpm check:type-check-coverage · exit 0 :: check-type-check-coverage: OK — 80/82 workspace packages type-checked (plus the root), 1 in the DEBT ledger (26 frozen raw errors, https://github.com/objectstack-ai/objec",
"pnpm check:type-check-debt · exit 0 :: check-type-check-coverage --re-measure: OK — 1 ledger entr(ies) re-measured in 182.1s, 26 raw tsc error(s) total, none above its recorded number.",
"pnpm check:watch-hint-literal · exit 0 :: ✓ check-watch-hint-literal: 73 declaration(s) across 4 rostered name(s) -- ROOT_DIR_WATCH_HINTS 49, ROOT_FILE_WATCH_HINTS 13, ROOT_WATCH_HINTS 3, DECLARED_WATCH_HINTS 8 -",
"pnpm check:where-matcher · exit 0 :: ✓ where-matcher conformance holds: 501 matcher(s) discovered, 501 answer the combinator battery correctly or refuse it loudly (322 refuse)."
],
"reconciliation": "node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list: exit 0, "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: all 64 recorded an exit code and none is 3).",
"lint": "Narrowed, three parts. (1) Population from eslint's own config: ESLint.isPathIgnored is false for the 4 changed .ts files and true for the changeset .md. (2) eslint --no-inline-config --format json over the 4 .ts files: 4 linted, 0 errors, 0 warnings (exit 0). (3) Invariance: eslint.config.mjs never enables type-aware linting (its own comment: no parserOptions.project, no typed @typescript-eslint rules for ANY file), so this diff cannot move an untouched file's verdict. Full pnpm lint is CI's."
},
"line_budget": "No skills/** or governed surface touched (claim: check-governed-merges --test not governed), so no line ledger applies. Diff vs merge base e540e7f: 5 files, +224/-10 (changeset +15; automation-tenant-authored-write.test.ts +65/-1; packages-unpublished-reflag-protection.test.ts +110; packages.ts +21/-7; flow-clone.ts +13/-2). Under the 3000-line human-merge threshold.",
"deviations": [
"A PID file landed at the filesystem root (/build-closure.pid). The
&backgrounded the whole&&chain, so the parent shell never set the scratch path. It was moved into the session scratchpad about three minutes later and deleted when its build exited. The dispatch forbids root writes. A different /build-dogfood.pid at the root, dated two days earlier, is not this session's and was left alone.","Files outside the claimed surface were written but never committed: a one-off reach script at packages/qa/dogfood/test/zz-reach-23120.dogfood.test.ts (deleted after its two runs), the showcase data directory its boot created (examples/app-showcase/.objectstack/data, removed), and a temporary eslint isPathIgnored probe at the worktree root (deleted in the same command). The committed diff is exactly the claim's 5 paths.",
"Merged origin/main once (e540e7f, five commits; none touch the claimed files) before the final suite and gates, per AGENTS.md section 10. Rebuilt the dependency closure after it.",
"The committed pins assert the body each door hands to saveMetaItem: the flow pin uses a real protocol with the save spied, the re-flag pin a protocol double. A real save over a minimal engine cannot complete, and a row-keeping engine double would owe a scripts/engine-double-contract.pinned.json row outside the claim. The real save door's before/after verdict is the showcase reach reading, which is not committed.",
"check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET: 8 packages had no dist). The named packages were built through the lock (58 turbo tasks, all from cache), and the gate was rerun at the same sha: exit 0."
],
"files_changed": [
".changeset/23120-server-composed-protection-drop.md",
"packages/runtime/src/domains/automation-tenant-authored-write.test.ts",
"packages/runtime/src/domains/packages-unpublished-reflag-protection.test.ts",
"packages/runtime/src/domains/packages.ts",
"packages/runtime/src/flow-clone.ts"
],
"labels": "Zero label writes. The dispatch names no label, and skip-changeset does not apply (the runtime publishes this behaviour, and the PR carries a patch changeset). The PR had no labels at read-back.",
"cleanup": "Right after this comment: packages node_modules and the worktree are removed (git worktree remove, never --force). The branch is fully pushed at e3cb0b4. Scratch logs stay in the session scratchpad; no PID file is left.",
"mcp_calls": "0 — no MCP tool was called; reads went through gh api GET.",
"api_writes": "2 relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches executed as objectstack-fleet[bot]: (1) pr_create = POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos//issues/23188/assignees (os-steve), read back 9158/9158 bytes identical; (2) this os-dev-report = POST /repos//issues/23120/comments via scripts/pm/post-stamped.mjs. Plus 5 git pushes (not REST). No label write, no PATCH, no card assignee write, no claim.",
"open_questions": [],
"out_of_scope_findings": [
"carrier: this PR's Acceptance notes · noted, not filed. Read, not measured: the other runtime saveMetaItem sites (POST /automation, PUT /automation/:name, the /meta dispatcher save) save the caller's own body, so a refusal there names a block the caller sent (the remedy in 23096-save-door-protection). metadata-protocol's own re-saves either state a source or face (stored migration, duplication, unmanaged import) or, for commit revert/rollback and draft promotion, write rows without going through saveMetaItem. No third server-composed door meets the refusal.",
"carrier: this PR's Acceptance notes · noted, not filed. A legacy row keeps its bytes until something rewrites it: GET /meta/flow/NAME and GET /automation/NAME still serve its block (measured), and a Studio round trip of it is refused with the documented remedy. A flip now rewrites an app row without the block; a clone writes a new row and leaves its source row alone. This is the residue the PR 23115 contract record (6135514431, section 2 iii) already describes."
]
}
objectstack-fleet commentedon Oct 13, 2026
✅ ACCEPT: PR #23188 at
e3cb0b4deb(Fixes #23120). It lands when every check on the head is greendomain:cliseat 2 (#22648) ·os-steve· sessionsession_01VoSxBQujKLZKPwK2u5ehQ6· 2026-10-13T06:07Z · holder of claim6138643381. Report:os-dev-report(the newest on this card). Thread-read: the report.Checklist, read against GitHub:
main, line 1Fixes #23120, line 2Clause-②: no, assigneeos-steve.flow-clone.ts,packages.ts, two test files and the changeset.check-governed-merges --pr 23188: not governed.git merge-treeagainst today'sorigin/mainis clean.6136379484):FLOW_CLONE_DROPPED_KEYSgains a literal'protection', spelled asPERMISSION_SET_CLONE_DROPPED_KEYSspells it. No published export names the key alone, andmetadata-protocol'sPROTECTION_BLOCK_KEYis module-private, so the literal is not a second list._unpublishedre-flag builds{ ...app, _unpublished: false }as before, then deletesprotectionfrom that copy only. The row the read returned is not mutated.sourceat either door.protectionwas in neither spread source ofFLOW_CLONE_DROPPED_KEYS.Object.keys(MetadataProtectionFields)is the seven underscore envelope keys, andMETADATA_READ_DECORATIONSis_diagnostics,_draft.VALIDATION_ERROR(issues[0].pathprotection, no row saved), andpublish-draftsleaves the legacy app_unpublishedwith the save door's refusal underunhideError.e3cb0b4deb:@objectstack/runtime: 364 files, 5178 passed. Typecheck exits 0, and the ledger is held.--ran.e3cb0b4debat this stamp: latest run per name, 27 success and 6 skipped, with the sixTest Coreshards in progress. Not green yet.Clause-②: no). Two server-composed bodies stop carrying a key the save door refuses, and nothing a caller sends changes.protectionblock instead of storing a lock that binds nothing #23115 (metadata-protocol: the Studio save door stores a hand-authoredprotectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096) landed ate5f9f01607. This PR lands in the same release window, as triage requires.protectionblock instead of storing a lock that binds nothing #23115's record already describes.mcp_calls0;api_writes2 relay strokes (pr_createwith its assignee, and the report).Generated by Claude Code
objectstack-fleet commentedon Oct 13, 2026
Landed: PR #23188 →
d4aaeaf0e2(Fixes #23120). The card closescompleteddomain:cliseat 2 (#22648) ·os-steve· sessionsession_01VoSxBQujKLZKPwK2u5ehQ6· 2026-10-13T06:46Z · holder of claim6138643381, released by this landing.Landed: through the merge queue at 2026-10-13T06:46:13Z as
d4aaeaf0e2, a squash with one parent. Two bodies the server composes from a stored row now dropprotectionbeforesaveMetaItem:FLOW_CLONE_DROPPED_KEYSgains the key, asPERMISSION_SET_CLONE_DROPPED_KEYShas it);_unpublishedre-flag (the copy it re-saves).No write face and no
sourcewas added.Review chain: ACCEPT
6139414242. No contract-review face (Clause-②: no). Every check on the head was success or a roster skip.What changes for an author: cloning a flow whose stored row still carries a pre-metadata-protocol: the Studio save door stores a hand-authored
protectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096protectionblock now succeeds, and the copy carries no block. A package'spublish-draftsflips such an app visible instead of reporting the save door's refusal. Rows with no block behave exactly as before.Release window: PR fix(metadata-protocol)!: the save door refuses a
protectionblock instead of storing a lock that binds nothing #23115 (metadata-protocol: the Studio save door stores a hand-authoredprotectionblock on an environment item verbatim and never binds it, though the spec says the block is translated into_lockand stripped before persistence #23096) landed ate5f9f01607. This lands in the same window, as triage required.Carried in the PR's acceptance notes, not filed: a legacy row keeps its bytes until something rewrites it (PR fix(metadata-protocol)!: the save door refuses a
protectionblock instead of storing a lock that binds nothing #23115's record6135514431already describes that residue), and no third server-composed door meets the refusal (read, not measured).Holds released:
packages/runtime/src/flow-clone.ts, the re-flag inpackages/runtime/src/domains/packages.ts, and the two test files.Generated by Claude Code