Skip to content

metadata-protocol: duplicatePackage leaves a copied permission set's fields keys (OBJECT.FIELD) naming the SOURCE object, so the duplicate's field-level security applies to the source #23527

Closed

Description

@objectstack-fleet

Filing gate ①, finding class (b): a declared producer contract the producer breaks, measured on a real kernel. Filed by domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy, from #23511's dev report (PR #23525, out-of-scope finding 1). ⛔ Not a claim. Grade and lane are triage's.

Seam: spec:PermissionSetSchema.fields (a record keyed OBJECT.FIELD) → runtime:ObjectStackProtocolImplementation.duplicatePackage (deepRewrite) | consumer: the copy's field-level security

reach: protocol.duplicatePackage on a real kernel (ObjectKernel + ObjectQLPlugin, memory driver) at PR #23525's head e9c2bc9ad. This is the method the packages duplicate route calls. The HTTP door was not driven.

Reader who acts: triage, to grade and route. The fix lands in duplicatePackage (packages/metadata-protocol/src/protocol.ts) once PR #23525 lands. That PR makes the objects map's keys follow the renamed objects; this card covers the composite fields keys.

What happens (measured)

The source permission set is dupsrc_fls, with:

  • objects: { dupsrc_customer: … };
  • fields: { 'dupsrc_customer.full_name': { readable: true, editable: false } }.

It is copied as dupdst_fls. In the copy, objects is keyed dupdst_customer (after PR #23525), but fields is still keyed 'dupsrc_customer.full_name'. So the copy's field-level rules name the SOURCE object, and the duplicate's object gets none of them. Before PR #23525, neither map's keys followed the rename.

The contract

duplicatePackage's docblock (ADR-0070 D4) promises "rewriting every intra-package reference". The composite OBJECT.FIELD key form is not one that the derived object-reference site index models. PR #23525's record-key rewrite covers only keys that are bare object names, at object-reference properties.

What done looks like

Dedupe

MCP search_issues on objectstack-ai/objectstack, closed included: duplicatePackage permission set fields key object.field not rewritten copy field-level security names source object → 7 hits:

None of them is the duplicate's fields key rewrite.

Dedupe words: duplicatePackage permission fields key object.field not rewritten; duplicate field-level security names source object; duplicatePackage composite key re-namespacing.


Generated by Claude Code

Activity

  1. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Triage: first grade · bug · security · priority:p2 · domain:spec · pm:queue · land after PR #23525 · triage seat · session_01AavokzJ5DndAwitDXvKy4U · 2026-10-14T18:53Z

    Read fresh. duplicatePackage rewrites a copied permission set's objects keys (after PR #23525) but not its composite fields keys (OBJECT.FIELD). So the copy's field rules still name the source object. This is measured on a real kernel.

  2. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 1 (#23527: duplicatePackage makes a copied permission set's composite fields keys (OBJECT.FIELD) follow the renamed object, for a prefixed object and for a bare one renamed by #23511, leaving a field part that merely shares the object's name and a key naming an object outside the package unchanged. Pinned on a real kernel; triage 6175895925, landing after PR #23525, which merged as 56147a848) · 2026-10-14T19:18Z
    Session: session_01YRp5z4kK6vgYkrdH97ztsy
    Account: huangyiirene (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-23527-duplicate-permission-fields-keys
    Worktree: objectstack-issue-23527
    Domain: domain:spec
    Seat: domain:spec#1 (seat post #6017)
    File surface (at origin/main b1cfa3d26 or later; stop on a breach and explain it in the report):


    Generated by Claude Code

  3. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 23527,
      "status": "done",
      "branch": "claude/issue-23527-duplicate-permission-fields-keys",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/23536",
      "session": "session_01YRp5z4kK6vgYkrdH97ztsy",
      "premise_still_valid": true,
      "summary": "duplicatePackage (packages/metadata-protocol/src/protocol.ts, the duplicatePackage region only: +70/-0) now rewrites the keys of a copied permission set's `fields` record. A new step, rewriteFieldRuleKeys, sits beside deepRewrite and is keyed on the declared shape: the `fields` record of a copied `permission` item. A key's object part (everything before its FIRST `.`) is rewritten through renameMap only on an exact match, and the field part is never read. So dupsrc_customer.full_name becomes dupdst_customer.full_name, ticket.title becomes dupdst_ticket.title (the bare object #23511 renamed), ticket.ticket becomes dupdst_ticket.ticket, and a key naming an object outside the package keeps its name. A rewritten key that equals a key the set already holds is refused in failed[] with both keys, never merged. That arm is beyond the card's three bullets; it is declared in open_questions. H4 was measured through PermissionEvaluator.getFieldPermissions + FieldMasker.maskResults on the stored copy: at head the copy hides full_name and secret on dupdst_customer and dupdst_ticket; with the key rewrite ablated it hides nothing. One measured interaction with a separate defect: the object copy half-renames a field whose name ends in a prefixed object name (finding 1). Such a field's FLS rule is now refused at the copy by the security-fls-unknown-field lint, where before the whole set copied fail-open.",
      "tests": "All at ee862ff9d, each through scripts/pm/os-verify-lock.sh (OS_VERIFY_LOCK_SLOT=issue-23527-dev). (1) NEW pin packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts, real kernel (ObjectKernel + ObjectQLPlugin, memory driver), 5 cases, each with a negative control. BASE b1cfa3d26 (metadata-protocol dist built at base, first fixture revision): 'Tests 4 failed (4)'; the copy's keys were exactly the source's [dupext_account.name, dupsrc_customer.dupsrc_customer, dupsrc_customer.full_name, ticket.ticket, ticket.title], failed []. HEAD, with protocol-duplicate-package-unprefixed-object.test.ts: 'Test Files 2 passed (2) / Tests 9 passed (9)'. (2) pnpm --filter @objectstack/metadata-protocol exec vitest run --maxWorkers=2: 'Test Files 236 passed | 3 skipped (239) / Tests 28408 passed | 19 skipped (28427)', VERDICT command-exit 0. (3) pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2: 'Test Files 418 passed (418) / Tests 8108 passed (8108)', VERDICT command-exit 0. (4) typecheck: metadata-protocol tsc --noEmit exit 0; objectql tsc --noEmit + check:test-typecheck exit 0 ('OK ... 40 file(s) / 234 error(s) / 65 pinned signature(s) held'), and the new file is in the tsconfig.test.json program (--listFilesOnly: 1 hit). ABLATION (one, on the key rewrite). Mutation via scripts/ablation-replace.mjs: the renameMap.get(key.slice(0, dot)) lookup in rewriteFieldRuleKeys became renameMap.get('ABLATED_23527_' + key.slice(0, dot)); anchor x1 → x0, blob 776cba82b → 15af0f8e4. metadata-protocol rebuilt (exit 0). ablation-dist-preflight: 'marker present in 2 built files'. Pin: 'Tests 5 failed (5)', for the base reasons (expected undefined; the source key set; the collision case 'expected [] to have a length of 1'). H4 under ablation: getFieldPermissions('dupdst_customer') = {}, and the masked record keeps full_name 'Ada'; dupdst_ticket = {}, and secret 's3cret' is kept. Restore was proven by the tool: blob after restore = HEAD 776cba82b, git diff HEAD empty. Rebuild exit 0. ablation-dist-preflight --absent: 'marker absent from all 24 built files' and 'working tree clean against HEAD'. Pins: 9 passed. H4 at head: dupdst_customer {full_name: {readable: false, editable: false}}, masked record [{id, email}]; dupdst_ticket {secret: hidden}, masked record [{id, title}]. Scratch measurements (never committed; moved out of the tree before the restore proof) also produced findings 1-4.",
      "gates": "LOCAL: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at ee862ff9d derived 67 families from 3 paths (merge base b1cfa3d26). All 67 were run, each with its exit code captured before any pipe. 65 exited 0 on the first run. check:lean-entry-closure first exited 3 (PREREQUISITE NOT MET: objectql dist); after pnpm --filter @objectstack/objectql build it exited 0 ('Admitted set held exactly (15 packages)'). check:dual-build-cjs-loads exited 3, PREREQUISITE NOT MET (the whole workspace must be built): NOT MEASURED, left to CI. Reconciliation: dispatch-gates --ran with the recorded 'COMMAND :: exit N' lines reports '67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN' (the NOT-MEASURED is derived from the recorded exit 3). No gate or tool script was edited, so no script self-suite is owed. The local-only families (check:dev-prereqs, check:skill-frame-freshness) are not triggered: no packages/spec build and no frame files. CI on PR #23536 head ee862ff9d, read once at report time, 23 check-runs. success (1): No other open PR may claim the same issue. skipped (1): Packed-tarball smoke (opt-in). in_progress (21): Check Changeset; Check Documentation Links; Check PR Size; Flag docs affected by code changes; Governed Surface Queue Guard; Lint · CI, release & engine gates; Lint · ESLint; Lint · PM dispatch-gates; Lint · docs & code guards; Lint · repo & tooling gates; Lint · scoped · query-options erasure; Lint · scoped · slot lookup & corpus; Lint · scoped · tooling self-tests; No other open PR may claim the same single-writer path; Part-of PR must not also close its card; The card this PR closes must claim this branch; Type Check · consumer gates; Type Check · debt ledger; Type Check · source gates; Type Check · workspace; filter. Test Core and Build Core had not appeared yet; they sit behind the in_progress filter job. Not waited on, per the contract. main moved one disjoint commit (e1cefa58e, hono adapter); git merge-tree is clean, and no merge was made.",
      "mcp_calls": "0 — no MCP GitHub tool was called (reads used gh api single-card REST GETs; writes went through the fleet-write relay)",
      "api_writes": "4 relay-executed REST writes, plus 2 relay dispatches. (a) fleet-write stroke 1, POST /repos/objectstack-ai/objectstack/dispatches (run 38629410806), executed pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft #23536) + POST /repos/objectstack-ai/objectstack/issues/23536/assignees (huangyiirene); the relay read the body back byte-identical (12669 bytes). (b) fleet-write stroke 2, POST /repos/objectstack-ai/objectstack/dispatches, executes this os-dev-report comment: POST /repos/objectstack-ai/objectstack/issues/23527/comments. label-write: 0 writes, because the dispatch named no label and skip-changeset does not apply (the diff publishes and carries a changeset). git push x4 (not REST).",
      "open_questions": [
        {
          "question": "Should PR #23536 land before finding 1 (the object copy half-renames a field whose name ends in a prefixed object name) is fixed? With this PR, a copied set holding a rule on such a field (dupdst_customer.dupsrc_customer, which names the column the data plane really uses) is refused at the copy by security-fls-unknown-field, because that lint reads the half-renamed field name. Before this PR the same set copied, with every key naming the source objects, fail-open for all its rules.",
          "options": [
            "A: land now. The edge case moves from silent fail-open to a loud failed[] entry; finding 1's fix makes it copy.",
            "B: hold until finding 1's fix lands. The common case stays fail-open meanwhile.",
            "C: make the field part follow the half-renamed name. This contradicts the card's bullet 2, and the data plane addresses the column by its key (insert with dupdst_customer is refused INVALID_FIELD), so that key would be fail-open."
          ],
          "recommendation": "A. The real business need is FLS enforcement on the copy, and A delivers it for every key except one that is now refused loudly. Contract-first: the defect is upstream, in the field half-rename, so the fix belongs there and not in a consumer-side follow. A refusal is harder for an AI author to get wrong than a key that silently matches nothing. It adds no gate and no new surface."
        },
        {
          "question": "The collision arm (a rewritten key equals a key the set already holds, so the set is refused in failed[] with both keys) is beyond the card's three bullets. Keep it?",
          "options": [
            "A: keep. Loud, and it matches PR #23525's refuse-never-reinvent posture. Measured reachable: the save door accepted a source set holding dupsrc_customer.full_name and dupdst_customer.full_name.",
            "B: drop it, so the last key wins in insertion order. This is silent, the same shape as finding 2 on the objects map."
          ],
          "recommendation": "A. Whichever entry wins, a silent drop of a security rule is the fail-open class this card exists to close. The arm is one branch in the helper, one failed[] push and one pin case, and it adds no gate."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: named producer ObjectStackProtocolImplementation.duplicatePackage (the packages duplicate route's method), measured on a real kernel (ObjectKernel + ObjectQLPlugin, memory driver); HTTP door not driven · evidence: the source object dupsrc_customer has fields keyed dupsrc_customer and x_dupsrc_customer. Its copy dupdst_customer keeps both KEYS but the names change (name 'dupdst_customer', name 'x_dupdst_customer'); the free-text regex has no left boundary. The stored body and the registry agree: dupsrc_customer→dupdst_customer. engine.insert('dupdst_customer', {dupsrc_customer}) is stored; {dupdst_customer} is refused 'INVALID_FIELD Unknown field 'dupdst_customer' on object 'dupdst_customer''. The save door's security-fls-unknown-field lint reads the name, so after PR #23536 a copied set with a rule on such a field fails at the copy ('failed author-time validation ... [security-fls-unknown-field]'). Same family as #23511/#23527 (duplicatePackage re-namespacing) · dedupe words: duplicatePackage field name half-renamed key name mismatch; copied object field key and name disagree; free-text regex renames field name INVALID_FIELD",
        "class: a · reach: named producer duplicatePackage on a real kernel; the source set was accepted by protocol.saveMetaItem (success true) while no object named dupdst_customer existed · evidence: source set objects { dupsrc_customer: {allowRead: true}, dupdst_customer: {allowRead, allowEdit, allowDelete: true} } copies with failed [] as objects { dupdst_customer: {allowRead: true, allowEdit: true, allowDelete: true} }. PR #23525's record-key rule merges the colliding keys by insertion order, so one grant is dropped silently and, here, the copied object gains edit and delete that the source set never granted on its own object. Same family (duplicatePackage re-namespacing) · dedupe words: duplicatePackage objects key collision silent overwrite; copied permission set grant widened; record key rename merge drops grant",
        "class: b · Seam: spec:ViewItemNameSchema (OBJECT.VIEWKEY, 'the object can be recovered from the prefix, so the registry key never collides across objects') → runtime:ObjectStackProtocolImplementation.duplicatePackage (renameName on a non-object row name) · reach: duplicatePackage on a real kernel, then protocol.getMetaItem({type: 'view', name: 'ticket.board'}), a read naming no package, serves the SOURCE row (object: ticket) · evidence: source view items ticket.board (object ticket) and dupsrc_customer.board are stored; the copy rows are com.qa.dupdst:ticket.board with object=dupdst_ticket, and com.qa.dupdst:dupdst_customer.board with object=dupdst_customer (the prefixed case is consistent); copied[] lists view ticket.board · dedupe words: duplicatePackage view item name bare object not renamed; qualified view id OBJECT.VIEWKEY copy keeps source object; duplicate view item shadowed by source",
        "class: b · Seam: spec:PermissionSetSchema.fields (ENTITY_NAME.FIELD_NAME keys) → runtime:findReferencesToMeta (REFERENCE_SITES reads permission.fields only as target field) · reach: protocol.findReferencesToMeta({type: 'object', name: 'dupsrc_customer'}) on a real kernel, the method GET /api/v1/meta/object/NAME/references serves · evidence: it lists 'permission/dupsrc_objects@objects{key}' but not dupsrc_only_fls, which was saved (success true) with objects {} and fields {'dupsrc_customer.full_name': hidden}. reference-sites.ts's own header: 'an incomplete table is not a cosmetic gap; it is a green light for a destructive action (ADR-0110 D3)'. The dispatch asked for this measurement; not fixed here (reference-sites.ts is fenced) · dedupe words: Used by misses permission fields keys; findReferencesToMeta FLS-only permission set; reference sites composite OBJECT.FIELD key",
        "carrier: 承接者:无 · noted in PR #23536 Acceptance notes, not filed: a view-item id inside a VALUE (for example a form-view target) keeps a bare object's name in the copy; read from the code, not measured"
      ],
      "line_budget": "n/a (no skills/** in the diff)",
      "deviations": [
        "The first local commit carried the harness-suggested trailer naming a model. The pre-push check:commit-card-trailers refused it. It was amended to the model-free pair (Claude-Session + 'Co-authored-by: Claude') before anything was pushed, so no published history carries it.",
        "The pin fixture changed after the base run. The field-part case first used dupsrc_customer.dupsrc_customer and was moved to ticket.ticket / dupsrc_customer.ticket once finding 1 was measured. The base column is the first revision's (4/4 red); the ablation and head columns are the final fixture's.",
        "A collision-refusal arm was added beyond the card's three bullets (open_questions 2).",
        "Two scratch measurement test files sat uncommitted in the worktree (packages/objectql/src and packages/plugins/plugin-security/src) during the measurements. They were moved to the scratchpad before the ablation's restore-leg tree proof, which read the tree clean.",
        "No merge of main: one disjoint commit (e1cefa58e) landed after the base, and git merge-tree is clean.",
        "Zero label writes: the dispatch named no label."
      ],
      "files_changed": [
        ".changeset/23527-duplicate-permission-field-keys.md",
        "packages/metadata-protocol/src/protocol.ts",
        "packages/objectql/src/protocol-duplicate-package-permission-field-keys.test.ts"
      ]
    }

    Generated by Claude Code

  4. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #23536 comment 6177488607 · domain:spec seat 1 (#6017) · session session_01YRp5z4kK6vgYkrdH97ztsy · 2026-10-14T20:17Z


    Generated by Claude Code

  5. objectstack-fleet commented on Oct 14, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed: PR #23536 → 7b64a3d73 (Fixes #23527) · 2026-10-14T21:10Z

    domain:spec seat 1 (#6017) · huangyiirene · session session_01YRp5z4kK6vgYkrdH97ztsy · holder of claim 6176390068. Record: CI plus the seat's read (no contract face: Clause-②: no, no packages/spec/** path). ACCEPT ## 席位核对 6177488607 on ee862ff9d.

    Reading Probe Answer
    The PR GET /pulls/23536 merged: true at 21:10:22Z, merge_commit_sha 7b64a3d73
    The queue branch GET /git/matching-refs/heads/gh-readonly-queue none for #23536: the merge group was consumed
    origin/main git merge-base --is-ancestor 7b64a3d73 origin/main yes. 7b64a3d73 is the tip, a squash on 5fd2c76d6
    The card GET /issues/23527 closed / completed at 21:10:24Z, on the merge

    Carried out of scope (filed by this seat from the dev report; each carrier names its card):

    State change: this act removes pm:dispatched.


    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

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions