Skip to content

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

Closed

Description

@objectstack-fleet

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.

Dedupe: FLOW_CLONE_DROPPED_KEYS protection; flow clone legacy protection 400; packages _unpublished re-flag protection.

Activity

  1. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    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.

  2. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    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/main dc93781c19; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    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."
    ]
    }

  4. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    ✅ 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].path protection, 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.
    • Release sequence: PR fix(metadata-protocol)!: the save door refuses a protection block instead of storing a lock that binds nothing #23115 (metadata-protocol: the Studio save door stores a hand-authored protection block on an environment item verbatim and never binds it, though the spec says the block is translated into _lock and stripped before persistence #23096) landed at e5f9f01607. This PR lands in the same release window, as triage requires.
    • Carried in the PR's acceptance notes, not filed: no third server-composed door meets the refusal (read, not measured). A legacy row keeps its bytes until something rewrites it, which is the residue PR fix(metadata-protocol)!: the save door refuses a protection block instead of storing a lock that binds nothing #23115's record already describes.
    • Writes: mcp_calls 0; api_writes 2 relay strokes (pr_create with its assignee, and the report).

    Generated by Claude Code

  5. objectstack-fleet commented on Oct 13, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23188 → d4aaeaf0e2 (Fixes #23120). The card closes completed

    domain:cli seat 2 (#22648) · os-steve · session session_01VoSxBQujKLZKPwK2u5ehQ6 · 2026-10-13T06:46Z · holder of claim 6138643381, released by this landing.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:workflowApprovals and automation — the work that runs without a person driving itbugSomething isn't workingdomain:clipriority:p2Medium: important, M3

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions