Skip to content

plugin-security: a data-door edit of a permission set saved into a writable runtime package forks it — the write-through's update leg saves without the row's package, leaving two active sys_metadata rows for one name #21861

Description

@objectstack-fleet

This card takes the write-through update leg's package binding. Parent #21789 keeps the lock and the layered-read echo (PR #21857). Raised from #21789's in-flight build by domain:services seat 1 (#6021), session_011K3zqE8Pv1Evw5hc8tZCnN. The seat owns it with the parent's domain and priority. It is serial after PR #21857, because both edit permission-set-projection.ts.

Blocked-by: #21789

What is measured (#21789's branch at e9dff47f):

  • A permission set saved through PUT /api/v1/meta/permission/:name?package=PKG (a writable runtime package) and then edited through the data door (PATCH /api/v1/data/sys_permission_set/:id) gets 200.
  • Two active sys_metadata rows now exist for the name: the package-bound one, unchanged, and a new package-less one carrying the edit.
  • The projected record reads managed_by: admin, package_id: null.

Why now: this is pre-existing on main (reachable before any list read, and through a package-less PUT /meta). Once PR #21857 stops the lock misfiring, the data door also accepts that edit after a list read, so it becomes reachable through the common Setup path.

Mechanism, as the dev read it (verify before acting): createPermissionSetWriteThrough's update leg (permission-set-projection.ts) calls saveMetaItem without the stored row's package binding.

Done when: a data-door edit of a package-bound set updates its own row (one active row per name and scope), and a door pin counts the rows before and after.

Positions: packages/plugins/plugin-security/src/permission-set-projection.ts (the write-through's update leg).


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Deferred, next free slot · seat domain:services#1 (#6021) · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T10:42Z. ⛔ Not a claim.

    The parent's PR #21857 has landed (c9be1f17), so this card is no longer blocked and moves to pm:queue. The seat's three dev slots are full (#21860, #21841, #21842), and this card takes the next one that frees. No open PR touches permission-set-projection.ts.

    Known pitfalls:

    • The package to save into is the stored row's own binding, read before the save. It is not the body's _packageId echo, and not a caller-supplied value. saveMetaItem already takes a packageId.
    • Only a writable runtime package's set needs the binding carried. A code-shipped set is refused earlier by the lock, and a package-less set must keep saving package-less. Telling these apart goes through the one classifier, classifyPackagedPermissionSet; ⛔ never a second evaluator.
    • The door pin counts the active sys_metadata rows for the name before and after the edit.

    Generated by Claude Code

  2. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-05T12:35Z
    Session: session_011K3zqE8Pv1Evw5hc8tZCnN
    Account: os-steve (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-21861-write-through-update-keeps-package
    Worktree: objectstack-issue-21861
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)
    File surface (at origin/main aead2968, which carries #21789's PR #21857), per the card (a seat-owned sub-issue of #21789):

    • packages/plugins/plugin-security/src/permission-set-projection.ts: createPermissionSetWriteThrough's update leg saves into the stored row's own package binding, so a data-door edit updates that row and never mints a second, package-less one. If the same fork is measured on another leg of the same write-through (same defect, same fix), it is in scope. Anything else is reported.
    • plugin-security tests, plus a door pin in a NEW file under packages/qa/dogfood/test/ (declared cross-lane on [PM seat] domain:cli — 🟢 marchtian · session_01B5CHJNXuuqzChM4w6hkTN4 #6024 in this act).
    • content/docs/** sentences this makes false, and a patch changeset.

    ⛔ No packages/metadata-protocol or packages/spec edit. ⛔ No new error code. ⛔ A code-shipped set's data-door edit keeps today's answer (the lock). ⛔ A package-less set's edit keeps saving package-less.
    Container & model: M, mode:subagent, model: opus (dispatch-gates --tier: no path-derived mandate; default tier).
    Clause-②: no


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report
    {
    "issue": 21861,
    "status": "done",
    "branch": "claude/issue-21861-write-through-update-keeps-package",
    "pr": "#21881",
    "session": "session_011K3zqE8Pv1Evw5hc8tZCnN (subagent run; the parent's harness-stamped id, as on every commit's Claude-Session trailer)",
    "premise_still_valid": true,
    "summary": "H1 confirmed and fixed. On origin/main 88a39c0 (carries PR #21857) the new door pin reproduced the fork: a data-door PATCH of a set saved via PUT /meta/permission/:name?package=PKG answered 200 and the active sys_metadata rows for the name went from 1 (package_id PKG) to 2 (the untouched PKG row + a package-less row carrying the edit). The update leg (saveMetaItem at :1374 on this base; the dispatch's :1297 is the insert leg) named no packageId. Fix, one file of production code (permission-set-projection.ts): a non-exported helper storedRowPackageArg reads the binding of the row the edit merges into (the envelope's overlay layer) from the metadata door's existing single-item read, protocol.getMetaItem, which serves that row through the same findServedOverlayRow resolution as the layered read and states its package_id as item._packageId; it is passed as packageId. No overlay layer or a package-less row leaves the call unchanged; code-shipped sets are still refused by the lock first. H2 measured before any edit: projected record managed_by admin / package_id null (does not carry the binding); layered overlay has no _packageId and envelope packageId null; getMetaItem item._packageId = PKG (REST and in-process). H3 held (pinned). H4: insert / restore / reconcile / delete legs read, none forks the same way (restore loses the binding instead and is unreachable, see notes); none changed. PR #21881 is a draft and assigned to os-steve.",
    "tests": "All at 32048af. RED repro on base dist 88a39c0: door pin 2 failed | 2 passed, received rows [{package_id:null,description:"Edited at the data door"},{package_id:"com.dogfood.bind21861",description:undefined}]. Green: pnpm --filter @objectstack/plugin-security exec vitest run --maxWorkers=2 → 167 files, 3604 passed, 45 skipped; plugin-security typecheck (incl. check:test-typecheck over tsconfig.test.json) and dogfood typecheck exit 0; after a fresh closure rebuild (turbo build --filter=@objectstack/dogfood^..., 63 tasks) dist preflight shows the fix spelling present and the ablation marker absent, permission-set-projection.test.ts 74/74, door pins permission-set-write-through-package-binding + permission-set-lock-row-provenance 18/18. Ablation (fix committed first): scripts/ablation-replace.mjs anchor "item: body, ...packageArg, ...actorArg });" x1→x0, replacement "item: body, ...actorArg });" x0→x1, blob e77bd871→4186f983, trap restore on EXIT/INT/TERM; rebuild exited 1 at DTS only (TS6133 packageArg unused) after the JS emitted, and ablation-dist-preflight found the marker in dist/index.js and dist/index.mjs; unit 2 failed | 72 passed (first red = row count, 2 rows), door pin 2 failed | 2 passed (2 active rows: PKG row undescribed + package-less row with the edit); restore blob == HEAD e77bd871, git diff HEAD empty, whole-tree git status empty, rebuild, preflight --absent 0 of 6 built files, unit 74/74, door 4/4. Narrowed lint: eslint --no-inline-config --format json over the 3 changed .ts files reported 3 files, 0 errors, 0 warnings. Population: the config globs **/*.{ts,...}; the 4th path is a .md changeset. Invariance: the config never sets parserOptions.project (no type-aware linting). Repo-wide pnpm lint is left to CI. No packages/cli path, so the integration tier does not apply.",
    "mcp_calls": "0",
    "api_writes": "3 - all through scripts/pm relay as objectstack-fleet[bot]: (1) fleet-write/dispatch.mjs pr_create → POST /repos/objectstack-ai/objectstack/dispatches (relay run 37320312800 → POST /repos/objectstack-ai/objectstack/pulls, draft, #21881; body read back 11106 bytes identical); (2) label-write.mjs --assign os-steve → dispatch (run 37320402726) → POST /repos//issues/21881/assignees, read back assignee os-steve; (3) post-stamped.mjs → POST /repos//issues/21861/comments (this os-dev-report). git push is not REST.",
    "open_questions": [],
    "out_of_scope_findings": [
    "carrier: none - restore leg re-authors package-less after a delete; a runtime-package set's delete removes its bound row (deleteMetaItem matches any package), so a restore would leave ONE package-less row (binding lost, not a fork). Different fix (the binding is gone with the row), and the leg is unreachable on a real engine (no trash state). Read, not measured; noted in Acceptance notes, not filed. Dedupe words: restore leg, package binding, permission set, write-through.",
    "carrier: none - degraded path unchanged: when the layered pre-read fails and the lock answers from the registry alone, the update leg has no envelope, reads no binding and saves package-less as before (needs a transient read failure then a successful save). Noted, not filed. Dedupe words: layered read failure, write-through, package-less save.",
    "carrier: none - a stored row bound to a read-only package (booted code package or system/cloud scope) with no artifact would now meet the protocol's read-only-base refusal instead of forking; no door mints such a row (saveMetaItem D1 and SysMetadataRepository.assertAllowed refuse that binding at write time), hence Clause-2 no. Noted in Acceptance notes."
    ],
    "gates": {
    "derived_by": "node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 32048af (70 commands; tool warned STALE TREE: 5 commits behind origin/main, 5 gate files changed across that range)",
    "commands": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0",
    "node scripts/check-adr-0087-registration.mjs --self-test :: exit 0",
    "node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0",
    "node scripts/check-changeset-no-major.mjs --self-test :: exit 0",
    "node scripts/check-ci-filter-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs :: exit 0",
    "node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs :: exit 0",
    "node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0",
    "node scripts/check-comment-mask-corpus.mjs :: exit 0",
    "node scripts/check-dts-emitted.mjs --self-test :: exit 0",
    "node scripts/check-empty-changeset.mjs --base origin/main :: exit 0",
    "node scripts/check-empty-changeset.mjs --self-test :: exit 0",
    "node scripts/check-issue-citations.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs :: exit 0",
    "node scripts/check-keyed-text-bounds.mjs --self-test :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs :: exit 0",
    "node scripts/check-platform-object-tenancy-census.mjs --self-test :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs :: exit 0",
    "node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0",
    "node scripts/check-registry-log-declared.mjs :: exit 0",
    "node scripts/check-registry-log-declared.mjs --self-test :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs :: exit 0",
    "node scripts/check-rest-log-spy-declared.mjs --self-test :: exit 0",
    "node scripts/check-system-context-census.mjs :: exit 0",
    "node scripts/check-system-context-census.mjs --self-test :: exit 0",
    "node scripts/check-tenant-audit-census.mjs :: exit 0",
    "node scripts/check-tenant-audit-census.mjs --self-test :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs :: exit 0",
    "node scripts/check-undeclared-dep-imports.mjs --self-test :: exit 0",
    "node scripts/docs-audit/check-affected-docs.mjs :: exit 0",
    "node scripts/docs-audit/check-drift-comment.mjs :: exit 0",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0",
    "node scripts/release-pending-publish.mjs --self-test :: exit 0",
    "pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0",
    "pnpm --filter @objectstack/spec run check:empty-state :: exit 0",
    "pnpm --filter @objectstack/spec run check:liveness :: exit 0",
    "pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0",
    "pnpm --filter @objectstack/spec run check:variant-docs :: exit 0",
    "pnpm check:changeset-gate-self-tests :: exit 0",
    "pnpm check:cross-package-test-inputs :: exit 0",
    "pnpm check:doc-authoring :: exit 0",
    "pnpm check:driver-memory-census :: exit 0",
    "pnpm check:dts-closure :: exit 0",
    "pnpm check:dual-build-cjs-loads :: exit 0",
    "pnpm check:engine-double-contract :: exit 0",
    "pnpm check:gitlink-declared :: exit 0",
    "pnpm check:i18n :: exit 0",
    "pnpm check:i18n-stale-fill :: exit 0",
    "pnpm check:issue-citations :: exit 0",
    "pnpm check:lean-entry-closure :: exit 0",
    "pnpm check:logger-receiver-detach :: exit 0",
    "pnpm check:nul-bytes :: exit 0",
    "pnpm check:objectql-double-limit :: exit 0",
    "pnpm check:objectui-changeset :: exit 0",
    "pnpm check:org-identifier :: exit 0",
    "pnpm check:page-declaration-shape :: exit 0",
    "pnpm check:pm-changeset-deadline-census :: exit 0",
    "pnpm check:published-files :: exit 0",
    "pnpm check:query-options-erasure :: exit 0",
    "pnpm check:refd-timer-probe :: exit 0",
    "pnpm check:slot-lookup :: exit 0",
    "pnpm check:sourcemap-no-sources-content :: exit 0",
    "pnpm check:swallow-census-controls :: exit 0",
    "pnpm check:test-source-alias :: exit 0",
    "pnpm check:tier-file-adoption :: exit 0",
    "pnpm check:type-check-coverage :: exit 0",
    "pnpm check:type-check-debt :: exit 0",
    "pnpm check:watch-hint-literal :: exit 0",
    "pnpm check:where-matcher :: exit 0"
    ],
    "reruns": [
    "pnpm check:dual-build-cjs-loads :: exit 3 on the first pass (PREREQUISITE NOT MET: 8 packages outside the dogfood closure had no dist/); built them (41/41 turbo cache hits), rerun :: exit 0 (the line above records the rerun)"
    ],
    "ran_verdict": "dispatch-gates --ran: 70 derived famil(ies) accounted for - 70 run, 0 NOT-MEASURED, 0 UNRUN (exit 0)",
    "added_vs_dispatch_list": [
    "node scripts/check-adr-0087-registration.mjs --base origin/main",
    "node scripts/check-adr-0087-registration.mjs --self-test",
    "node scripts/check-empty-changeset.mjs --base origin/main",
    "node scripts/check-empty-changeset.mjs --self-test",
    "node scripts/pm/release-rehearsal-clone.mjs --self-test",
    "node scripts/release-pending-publish.mjs --self-test",
    "pnpm check:objectui-changeset",
    "pnpm check:pm-changeset-deadline-census"
    ],
    "ci": "not waited for (in_progress at report time is the honest value)"
    },
    "line_budget": "n/a",
    "deviations": [
    "Container restart at ~13:15Z stopped the run mid gate battery (9/70 recorded). That partial record was discarded as NOT MEASURED. The closure was rebuilt, the final tests were re-run, and the whole 70-command battery was re-run from scratch. Typecheck and the full plugin-security suite had completed with VERDICT lines before the restart, at the same HEAD 32048af.",
    "origin/main was not merged: it moved past base 88a39c0 (now includes #21871, a metadata-protocol package-scoped LIST-merge change that does not touch getMetaItem single-item stamping or findServedOverlayRow), and #21860 / PR #21873 is still open, which was the dispatch's merge condition.",
    "The dispatch's H1 line reference (:1297) is the insert leg on #21857's head; the update leg is the saveMetaItem at :1374 on this base.",
    "Labels: none written beyond the PR assignee. The dispatch named none, and a changeset is present, so skip-changeset does not apply. size/m on the PR was set by another actor and left alone.",
    "Commit trailers are the model-free pair (Claude-Session + Co-authored-by: Claude) per AGENTS.md and the pre-push hook, not the harness reminder's model-named Co-Authored-By. The PR body ends with the dispatch's session-URL footer instead of the harness's alternate footer lines.",
    "The ablation rebuild exited 1 at the DTS step (TS6133, packageArg unused once the save stopped reading it). ESM/CJS were already emitted, and ablation-dist-preflight proved the mutation in both built JS files before any run was read.",
    "No docs edit: no content/docs sentence is made false. The older permission-sets.mdx overlay drift is the one PR #21857's review already noted."
    ],
    "files_changed": [
    ".changeset/21861-write-through-update-keeps-package.md",
    "packages/plugins/plugin-security/src/permission-set-projection.ts",
    "packages/plugins/plugin-security/src/permission-set-projection.test.ts",
    "packages/qa/dogfood/test/permission-set-write-through-package-binding.dogfood.test.ts"
    ]
    }


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Review: PR #21881 at 32048afa · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T14:01Z

    Verdict on the dev report 5995932961 (status: done), checked against GitHub and the tree. Accepted as built. No contract review is owed: the claim's Clause-②: no stands, and no packages/spec path is touched.

    Checked:

    • H1 confirmed on the door. At base, a data-door edit of a set saved into a writable runtime package answered 200 and left two active sys_metadata rows for the name: the package's row untouched, plus a package-less row carrying the edit. (The update leg's saveMetaItem is about :1374 on this base, not the :1297 the order named; that one is the insert leg.)
    • The fix: a non-exported helper, storedRowPackageArg, reads the binding of the row the update merges into (the envelope's overlay layer) through the metadata door's own single-item read, getMetaItem, which serves that row by the same resolution as the layered read and states its package_id as _packageId. The save passes it as packageId. The helper never reads the binding from the patch, the projected record or a registry item.
    • H2 measured before any edit: the projected record carries no binding (managed_by: admin, package_id: null), and neither does the layered overlay. Only the metadata door's item read does.
    • No stored row leaves the save package-less, exactly as before. A code-shipped set is still refused by the lock first. A failed read is not caught, so the save never guesses which row it lands in.
    • Ablation (the package argument dropped from the save, preflighted into dist) turned 2 unit pins and 2 door legs red (two active rows). The restore was proven.
    • Gates: 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN, re-run from scratch after the container restart. The partial battery from before the restart was discarded as NOT MEASURED.

    H4: the insert, restore, reconcile and delete legs were read. None forks the same way, and none is changed.

    Carried, not filed (each in the PR's Acceptance notes):

    • The restore leg would re-author a runtime-package set package-less after a delete: the binding is lost, but there is no fork. A different fix would be needed, and the leg is unreachable on a real engine.
    • When the layered pre-read fails, the update has no envelope and saves package-less, as before.
    • A row bound to a read-only package would meet the protocol's read-only refusal instead of forking. No door mints such a row.

    Merge order: PR #21873 (#21860) has landed on other files of this package. The merge queue tests this PR against the current main.


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 5, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · seat domain:services#1 · session_011K3zqE8Pv1Evw5hc8tZCnN · 2026-10-05T15:09Z

    • PR fix(plugin-security): a data-door edit of a permission set saved into a writable runtime package updates its own row instead of forking it #21881 merged through the merge queue as 07c842df, which is an ancestor of origin/main (verified with git merge-base --is-ancestor after a fetch). Merged at 2026-10-05T15:08Z.
    • Fixes #21861 closed this card completed. In this act the seat clears the pm:dispatched state label and the assignee os-steve. The domain:services, area:access, priority:p2 and bug labels stay.
    • What shipped: a data-door edit of a permission set saved into a writable runtime package saves into that row's own package, read through the metadata door's item read. The set keeps one active sys_metadata row, and the edit lands on it. A package-less set still saves package-less, and a code-shipped set is still refused by the lock first.
    • Carried, recorded in the seat verdict 5996042029 and the PR's Acceptance notes: the restore leg would re-author such a set package-less (unreachable on a real engine); a failed layered pre-read still saves package-less, as before.

    Generated by Claude Code

  6. added a commit that references this issue on Oct 7, 2026
    07c842d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:accessPermissions that actually hold — RLS/FLS, sharing model, write-path guardsbugSomething isn't workingdomain:servicespriority:p2Medium: important, M3

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions