Skip to content

fix(metadata-protocol): an object's references list a permission set named only by its fields keys (#23539) - #23554

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23539-references-permission-fields-keys
Oct 15, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-23539-references-permission-fields-keys

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23539

Clause-②: no

Summary

findReferencesToMeta serves GET /api/v1/meta/object/:name/references, the admin "Used by" panel. It now lists a permission set that names an object only in its fields keys. PermissionSetSchema.fields is keyed OBJECT.FIELD, and the security plugin applies a set's field rules to an object by that key prefix (PermissionEvaluator.getFieldPermissions). Before this change, such a set was missing from the object's answer. The panel could then read "Safe to delete." while the set still masked the object's fields.

What changed

  • packages/metadata-protocol/src/reference-sites.ts. A composite-key seed, COMPOSITE_KEY_REFERENCE_SITES: permission.fields names an object through the text before a key's first .. It is exposed as a new index member, compositeKeyByTarget, and kept OUT of byTarget.
    • Both premises are derived from the schemas, in the nameVocabularyRejectsKey style, and fail closed. permission must still declare fields as a root record, and ObjectSchema.name must still reject .. If either stops holding, the seed drops out and the derivation pin goes red.
    • The seed's target is typed 'object' alone.
    • nameVocabularyRejectsKey's schema read is factored into nameVocabularyVerdict. Its answers are unchanged: the existing unanswerableTargetTypes pins still pass.
  • packages/metadata-protocol/src/protocol.ts, two regions only.
    • A new collectCompositeKeyHeadHits, placed beside collectReferenceHits. referencedNamesAt and collectReferenceHits are byte-unchanged.
      • It reads only the seeded root record's keys, and matches the text before the FIRST . (dot index above 0).
      • A key's field part is never read. A key with no object part (.full_name) names no object.
      • One row per site, at path fields{key}.
    • findReferencesToMeta folds the composite sites into the same one-read-per-source-type matchers. The new row's kind is permission fields. Every existing row, the response shape and the field target's 501 are unchanged.
  • duplicatePackage is not touched, and its byTarget.get('object') reading is unchanged.

Why the seed is not a byTarget row

This was measured in round 1 (report comment 6179812710, experiment E1) with a plain { permission, fields, object } row in byTarget. The answer did not change, because the walk compares a WHOLE value with the object's name. And duplicatePackage broke. It reads byTarget.get('object') as a flat set of object-naming property names, so a copied object's own fields key was renamed. protocol-duplicate-package-unprefixed-object.test.ts went red, with noteCopy.fields.ticket undefined. The derivation pin now asserts that byTarget('object') has no permission.fields site.

Why the split is the first .

ObjectSchema.name and FieldSchema.name are both ^[a-z_][a-z0-9_]*$, so neither can contain .. The evaluator (key.startsWith(object + '.')), lint security-fls-unknown-field and duplicatePackage's field-rule rewrite all make the same split.

Save-door readings (saveMetaItem, real kernel):

  • A deeper key on an in-stack object (customer.full_name.extra) is refused with 422 security-fls-unknown-field.
  • A bare key is refused with 422 security-fls-unqualified-key.
  • A key for an out-of-stack object (no_such_object.x) and an empty-object-part key (.full_name) are stored.

Evidence (final head 8ca891b1d5)

  • Real-kernel pin, packages/objectql/src/protocol-references-permission-field-keys.test.ts: ObjectKernel + ObjectQLPlugin + memory driver; every item passes the real save door.
    • On main (round 1, 9e67e008e1 over 7b64a3d73d): Tests 2 failed | 2 passed (4). The two failures were expected [ Array(1) ] to include 'refsrc_customer_fls@fields{key}' and expected [] to deeply equal [ 'refsrc_invoice_fls@fields{key}' ].
    • After the fix: Tests 4 passed (4).
    • The pin covers four cases. (1) The set is listed at fields{key}. (2) Negative control: a set whose keys name only another object is not listed, even where its FIELD part spells the asked object; it is listed once for the object it does name. (3) The objects{key} row is unchanged. (4) The field target still refuses with NOT_IMPLEMENTED / 501.
  • field target byte-identity. A real-kernel probe captured findReferencesToMeta({ type: 'field', name: 'refsrc_customer.full_name' }) before and after the fix. Both runs gave NOT_IMPLEMENTED 501 refusal:true, message sha256 7b4aee8698a564f69599ba38e1b3f54ec2623cd5336a8e57e267b093ea4b2457. The probe was a throwaway file, deleted afterwards.
    • Before, refsrc_customer answered the grant row only. After, it answers that same row plus the new fields{key} row.
  • Ablation. The walk branch was deleted: the line calling collectCompositeKeyHeadHits in findReferencesToMeta.
    • Mutation: ablation-replace.mjs, anchor 1→0, blob 5be7b1c1adc7→09028903ed91.
    • Positive control on the pristine build: the call marker is present in dist/index.js and dist/index.cjs.
    • The rebuild's ESM/CJS emitted. DTS refused with TS6133 (the helper was now unused), which does not affect the JS artifacts the suite loads.
    • ablation-dist-preflight --absent: marker absent from dist.
    • Pin: Tests 2 failed | 2 passed (4), the same two listing assertions.
    • Restore: blob == HEAD with git diff HEAD empty. The rebuild re-emitted both declaration files (check-dts-emitted 2/2). Preflight: marker present in both JS files, and the tree is clean. Pin: Tests 4 passed (4).
  • Suites.
    • pnpm --filter @objectstack/metadata-protocol exec vitest run: Test Files 236 passed | 3 skipped (239), Tests 28412 passed | 19 skipped.
    • pnpm --filter @objectstack/objectql exec vitest run --project local: Test Files 419 passed (419), Tests 8112 passed. This includes protocol-references, protocol-duplicate-package-unprefixed-object and protocol-duplicate-package-permission-field-keys.
    • reference-sites.derivation.test.ts: Tests 25 passed (25).
  • Typecheck.
    • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0. --listFiles includes reference-sites.ts, protocol.ts and reference-sites.derivation.test.ts.
    • pnpm --filter @objectstack/objectql typecheck: exit 0, with check:test-typecheck: OK — 40 file(s) / 234 error(s) / 65 pinned signature(s) held.
  • Derived gates at 8ca891b1d5: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 67 commands from the 5 changed paths. All 67 were run, each exit captured before any pipe. 66 exited 0. pnpm check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET: it needs about 70 packages built, which this worktree does not hold, so it is NOT MEASURED here and CI builds them. pnpm check:lean-entry-closure first exited 3 on a missing packages/objectql/dist; after an objectql build it was re-measured at exit 0. --ran reconciliation: 67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN. The 11 wide-population families and the 6 workflow-valued families are CI's.

Acceptance notes

  • The new row's path spelling fields{key} follows the existing objects{key} grammar. A set naming an object in both objects and fields answers two rows, one per path. That is the walk's existing one-row-per-path behaviour.
  • Noted, not filed: the field target's refusal text says every property that names a field holds the bare field name. permission.fields is the one derived field site whose keys are the composite form, so the sentence is inaccurate for that one site. The refusal itself stays honest, and the card keeps the field answer unmoved.
  • Noted, not filed: duplicatePackage reads byTarget('object') as a source-type-blind property-name set. This is why the seed lives in its own map, and the derivation pin guards it.
  • A save-door finding about the empty-object-part FLS key (.full_name) went to the seat for filing. It is out of this PR's scope.

Generated by Claude Code

…ed only by its fields keys

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
…s keys in an object's references

A composite-key site (permission.fields, keyed OBJECT.FIELD) is seeded in
the derived reference index, kept out of byTarget, with derived premises:
the source still declares fields as a root record and ObjectSchema.name
still rejects '.'. findReferencesToMeta reads it through its own walk,
matching the text before a key's first '.', at path fields{key}.

Claude-Session: https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 14, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 15 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via getReferences (sdk, the bare tail of client method meta.getReferences, bound to GET /api/v1/meta/:type/:name/references), meta.getReferences (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/references, selected by route anchor /:type/:name/references))
  • content/docs/ui/react-pages.mdx (via /:type/:name/references (route, bridged from symbol findReferencesToMeta — its route source's handler names it))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via findReferencesToMeta (symbol, a method of class ObjectStackProtocolImplementation))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 8226755ee94cd6fe26a1580daea58251e4af99ad → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 4c0b45469a3fe69691124ff611510106e7396397 — the merge of head 8ca891b1d5e9a83ee57b6ed14bf4f67c0e8eeb73 into base 8226755ee94cd6fe26a1580daea58251e4af99ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 4c0b45469a3fe69691124ff611510106e7396397 && git checkout 4c0b45469a3fe69691124ff611510106e7396397
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8226755ee94cd6fe26a1580daea58251e4af99ad 8ca891b1d5e9a83ee57b6ed14bf4f67c0e8eeb73 && git checkout -B drift-repro 8226755ee94cd6fe26a1580daea58251e4af99ad && git merge --no-ff 8ca891b1d5e9a83ee57b6ed14bf4f67c0e8eeb73

node scripts/docs-audit/affected-docs.mjs --json 8226755ee94cd6fe26a1580daea58251e4af99ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 8226755ee94cd6fe26a1580daea58251e4af99ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

domain:spec seat 4 (#18917) · sunhaolin · session session_0121ooBYRmS8ziFXm9ai5YHm · claim 6178702290 (amendment 6179854093) on #23539 · reviewed head 8ca891b1d5 · 2026-10-14T23:14Z

Verdict: ACCEPT. Not governed (check-governed-merges --pr 23554). 5 files, +469 / −12. Clause-②: no: the references answer keeps its declared shape and gains the missing row, and no door changes what it accepts. No contract review is owed; this record is the review.

  1. Shape. Draft PR. The first line is Fixes #23539, and Clause-②: no sits at line start. The changeset is @objectstack/metadata-protocol patch.
  2. Scope against the amended claim. Route A, as accepted.
    • reference-sites.ts gains a composite-key seed with derived, fail-closed premises: permission still declares fields as a root record, and ObjectSchema.name still rejects .. It sits in its own compositeKeyByTarget map, never in byTarget.
    • protocol.ts changes only in the two amended regions:
      • a new collectCompositeKeyHeadHits after collectReferenceHits, with referencedNamesAt and collectReferenceHits byte-unchanged;
      • the matcher fold inside findReferencesToMeta.
    • duplicatePackage has 0 hunks.
  3. The change, read in the diff.
    • A key names the target only when the text before its FIRST . equals the target's name, which is the split getFieldPermissions makes.
    • A key with no ., or with a . at index 0, names nothing.
    • The field part is never read, so invoice.customer is not a reference to customer.
    • There is one row per site, at path fields{key}, with kind permission fields.
    • A source type named only by a composite site still gets its matcher, on the same one-read-per-source-type walk.
  4. Evidence.
  5. Out-of-scope dispositions.
  6. Landing clauses.
    • (a) Every check is green or skipped by design: read at landing. 17 runs were in progress at this record.
    • (b) mergeable_state is clean at enqueue: read at landing.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 23:41
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 14, 2026 23:41
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 7ffc578 Oct 15, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23539-references-permission-fields-keys branch October 15, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants