Skip to content

feat(spec): export the controlled_by_parent exactly-one-master predicate; lint reads it - #23496

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23272-export-cbp-master-predicate
Oct 14, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-23272-export-cbp-master-predicate

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #23272

Clause-②: yes

What

@objectstack/spec/data now exports the controlled_by_parent exactly-one-master predicate (ADR-0055 §1), from the module that owns it (packages/spec/src/data/controlled-by-parent-master.ts). ObjectSchema's refusal and @objectstack/lint's cbpMasterCandidates read it, so a producer, the parse and the linter cannot drift.

  • controlledByParentMasterRefusal(objectName, sharingModel, fields) (already the parse's judge, now public with its types ControlledByParentMasterRefusal and ControlledByParentMasterShape) answers the object-level question "does this object satisfy the exactly-one-master rule?". undefined means yes. A refusal carries the parse issue's path and message, the object's shape, and its master_detail and required lookup fields.
  • isControlledByParentMasterField(field) (new) answers the field-level half, "does this field count as the master?": a master_detail declared required: true, and nothing else. The refusal now counts with it.

Why these two, and not one

The card's direction asks for one predicate that both ObjectSchema and the lint read. Measured on main, they need different granularity:

  • The producer (cloud's packages/service-ai-studio/src/tools/sharing-model.ts, defaultSharingModel) needs the object-level verdict for a field map it is about to write: controlledByParentMasterRefusal(name, 'controlled_by_parent', fields) === undefined. The refusal function already returns exactly that, along with the shape and message, so no narrower boolean wrapper is added.
  • The lint cannot read only the object verdict without changing findings. cbpMasterCandidates lists each candidate with its reference for the ambiguous-relation message. It reads the array field form as well as the map. It also does not count a master_detail that names no reference (pinned by errors when the required master_detail names no reference target and picks up a later relation when an earlier one names no target). So the lint reads the field-level predicate, which is the part the refusal counts, and keeps its refOf filter and its own length checks for its two rule ids.

What counts as a master is now spelled once, in the spec.

Landing spot

This is the claim's file surface. The owning module is controlled-by-parent-master.ts, not object.zod.ts as the card says. object.zod.ts already called it, so object.zod.ts is unchanged. The re-export is in packages/spec/src/data/index.ts. Dependency direction: @objectstack/lint already imports @objectstack/spec/data (referenceCarrierOf), so no new edge and no cycle.

Pins

Ablation (run once, not kept)

The fix was committed first. scripts/ablation-replace.mjs then made isControlledByParentMasterField also count a required lookup (anchor 1 → 0, blob 1500e7c092d0 → f3afdbb79519), and the spec was rebuilt. ablation-dist-preflight found the marker in 24 built files, which proves the lint resolves through dist/.

  • validate-security-posture.test.ts: 6 failed (e.g. errors on a REQUIRED lookup (the retired third tier), the parse and the lint agree on a required lookup). So the lint reads the spec predicate.
  • controlled-by-parent-master.test.ts: 3 failed (a required lookup only, one required master_detail beside a lookup, and the field truth-table row).
  • Restore: blob equals HEAD (1500e7c092d0), git diff HEAD is empty, the spec was rebuilt, and --absent found the marker in none of 232 built files. The tree is clean.

Tests and gates

All readings below are at head d88df525c, after the merge of origin/main. Every heavy run went through os-verify-lock.sh.

  • pnpm --filter "@objectstack/lint..." build: exit 0. This also rebuilt @objectstack/spec.
  • pnpm --filter @objectstack/spec check:generated: "All 14 generated artifacts are up to date". api-surface/data.json and export-origins/data.json were regenerated in this PR: three functions and types added to the data entry, nothing removed.
  • pnpm --filter @objectstack/spec test: 677 files, 20599 passed | 1 todo.
  • pnpm --filter @objectstack/spec test:repo: 60 files, 1029 passed. This was run at the pre-merge head 7c40b9b6b. The merge brought nothing into the files this PR touches.
  • pnpm --filter @objectstack/lint test: 139 files, 6726 passed.
  • pnpm --filter @objectstack/spec typecheck and pnpm --filter @objectstack/lint typecheck: exit 0. These ran before the merge, and the merge did not touch spec/src/data or lint/src.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 88 derived families, all exit 0. The --ran reconcile says "88 run, 0 NOT-MEASURED (a DERIVED zero — all 88 recorded an exit code and none of them is 3)".
  • check:nul-bytes: exit 0.
  • Consumers: no accept or refuse result changes in packages/spec. The refusal function body only swaps an inline filter for the extracted predicate, as the 20599 spec tests and the new pin show. So no consumer package owes a re-run beyond @objectstack/lint, which is the one importer of the new export.

Acceptance notes

  • The runtime's resolveCbpRelation (packages/plugins/plugin-security/src/security-plugin.ts) still keeps its own reading. It reads the unparsed registry with the reference aliases, and this card's direction names only the parse and the lint. Not changed here. carrier: none.
  • controlledByParentMasterRefusal answers undefined when fields is not an object map, as it did before. A producer must pass the field map it writes. The JSDoc and the changeset say so.
  • Cloud's defaultSharingModel (at cloud main today) counts any master_detail with required !== false, and so treats an omitted required as a master. ObjectSchema treats that as master-not-required on the authoring path. Adopting the export makes cloud's answer the parse's. That adoption is cloud's change, not this PR's.

Generated by Claude Code

@github-actions

github-actions Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 4 documentable anchor(s). ⚠️ 4 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/api/error-catalog.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/data-modeling/objects.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/deployment/cli.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/getting-started/common-patterns.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/kernel/runtime-services/sharing-service.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/authorization.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/index.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/permissions-matrix.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/rls.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/permissions/sharing-rules.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/kernel/error-handling.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/protocol/objectql/security.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))

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

  • content/docs/releases/implementation-status.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/releases/v15.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/releases/v17/17-1.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/releases/v17/17-2.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/releases/v17/17-3.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))
  • content/docs/releases/v17/index.mdx (via controlled_by_parent (literal, a string literal in a comment on a changed line))

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
  • 4 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: master_detail (literal, 33 pages)
  • 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 — 142 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 873151021f640a7a2b8675f2c38ff10ec8a7230b → packageMentionDocs.

Which tree this was computed on

This run read content/docs from a5bb85c821e2adf117d36d51fd6330761a22739a — the merge of head 7b2581304820cd8e9fc1b9ad336b67ba6b42ada5 into base 873151021f640a7a2b8675f2c38ff10ec8a7230b, 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 a5bb85c821e2adf117d36d51fd6330761a22739a && git checkout a5bb85c821e2adf117d36d51fd6330761a22739a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 873151021f640a7a2b8675f2c38ff10ec8a7230b 7b2581304820cd8e9fc1b9ad336b67ba6b42ada5 && git checkout -B drift-repro 873151021f640a7a2b8675f2c38ff10ec8a7230b && git merge --no-ff 7b2581304820cd8e9fc1b9ad336b67ba6b42ada5

node scripts/docs-audit/affected-docs.mjs --json 873151021f640a7a2b8675f2c38ff10ec8a7230b

⚠️ 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 873151021f640a7a2b8675f2c38ff10ec8a7230b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…ternal — it is re-exported from @objectstack/spec/data

Claude-Session: https://claude.ai/code/session_01D6F9axfoPzR7t8G1J7Ytzz
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 7b2581304820cd8e9fc1b9ad336b67ba6b42ada5
Local-runs: none

Inputs read: card #23272 (body, triage 6147613353, claim 6166328411, os-dev-report 6169637194), PR #23496 (body, file list, net diff against main at the earlier head d88df525c, the touched files at that head), the one-commit delta d88df525c..7b2581304 (compare API: one file, +2/-1), and the check-runs on 7b2581304 (read once). Card #23270's body was read for the shape list the triage pin names. This record supersedes the one rendered on d88df525c.

① Derived judgments

Delta d88df525c → 7b2581304 (commit 7b2581304, docs(spec): …): packages/spec/src/data/object.zod.ts lines 25-27 only — the stale "Internal: not re-exported by data/index.ts." sentence becomes "Public: re-exported from @objectstack/spec/data, with the field predicate isControlledByParentMasterField it counts." No code token changes; the import line and refuseControlledByParentWithoutOneMaster are untouched. Right — it closes the one reviewer finding of the earlier record, and the file is in the claim's surface. Every judgment below is carried forward unchanged, since the delta touches no export, baseline, test or lint source.

Public surface widened — @objectstack/spec/data only (export * from './controlled-by-parent-master', packages/spec/src/data/index.ts:366). The module holds exactly four exports, and api-surface/data.json and export-origins/data.json each gain exactly those four and remove nothing, so the baselines say what the diff publishes:

  1. controlledByParentMasterRefusal(objectName: unknown, sharingModel: unknown, fields: unknown): ControlledByParentMasterRefusal | undefined — right. Pre-existing body, already the judge ObjectSchema's refuseControlledByParentWithoutOneMaster (object.zod.ts:2002-2011) calls; the only body change swaps the inline type === 'master_detail' && required === true filter for the new field predicate, so the verdict, path and message on every shape are unchanged. undefined for a non-controlled_by_parent model, for a non-map fields, and for the satisfying shape; this is the "one predicate" the triage asked for, at object level, and a producer asks === undefined. Reachable through the return: path ('fields' | 'sharingModel'), shape, masterDetails, requiredLookups, message — all readonly, no schema or zod object leaks.
  2. isControlledByParentMasterField(field: unknown): boolean — right. New, total over unknown (null/primitive → false), spells master_detail + required === true once; the refusal counts with it, the lint filters with it. It is the field-level half the lint needs (per-candidate reference, array-form fields) without changing lint findings — the PR's "why two" explanation holds on the diff: reading only the object verdict would drop the refOf filter and the per-candidate listing the two lint rule ids are pinned on.
  3. ControlledByParentMasterShape (type) and ControlledByParentMasterRefusal (interface) — right. They are the return type of export 1; publishing the function without them would make the return un-nameable by a consumer.

Landing spot vs. triage direction (6147613353: one predicate, in object.zod.ts, read by ObjectSchema and lint, pinned on #23270's shapes). Judged right with the dev's deviation. The rule's owning module on main is controlled-by-parent-master.ts (#23387 moved it there; object.zod.ts only calls it), and the claim's file surface already names that module as the export's home. Re-exporting from the owner rather than duplicating in object.zod.ts is the "one rule, one spelling" rationale the triage gives. Two exports instead of one: the object-level predicate is export 1; export 2 is the count it is made of, and it is what lets the lint read the spec without altering pinned findings. Both ObjectSchema and lint now read the spec module, which is the drift guarantee the direction asks for. The runtime's resolveCbpRelation (plugin-security) keeps its own reading; the direction names only the parse and the lint, so that is outside this card and correctly left.

Accept-set changes in packages/spec: none. object.zod.ts carries only the comment edit; the refusal function's verdict table is the same outcome on every input. The new pin (controlled-by-parent-master.test.ts) holds the export equal to ObjectSchema.safeParse on verdict, path and message for each #23270 shape (several-required-masters, several-masters, master-not-required with required: false and omitted, required-lookup-only, no-master), the two satisfying shapes (accepted whole), and the three other sharing models — the triage pin as stated.

@objectstack/lint findings: unchanged. cbpMasterCandidates (validate-security-posture.ts:346-350) goes from f.type === 'master_detail' && f.required === true && refOf(f) to isControlledByParentMasterField(f) && refOf(f) — identical truth table (the predicate is the same two comparisons; recordsOf still feeds both map and array forms). Its .map (field/type/master), resolveCbpRelation's length === 1, pushCbpNoRelation, pushCbpAmbiguousRelation and every rule id, path and message text are not in the diff. No lint test case was edited except the #5017 receiver meta-test row. The import edge @objectstack/lint → @objectstack/spec/data already existed (referenceCarrierOf), so no new dependency.

#5017 receiver meta-test (validate-security-posture.test.ts:1509-1513): the f row drops required — right. At the head the lint file has no remaining .required read (grep on the head file: zero hits), so the row now states the file's actual reads; the comment records why.

Mirror/baseline obligations: api-surface and export-origins regenerated in the PR (+4/+4, 0 removed). Check Changeset, Spec property liveness, Governed Surface Queue Guard and Flag docs affected by code changes are success on this head.

② Semver level

.changeset/23272-export-cbp-master-predicate.md: @objectstack/spec: minor, @objectstack/lint: patch — matches the diff. spec adds four exports to a published subpath and removes or alters nothing (minor, not patch; not major since no accept/refuse or export change). lint swaps an internal filter for an imported one with no finding change (patch). The changeset body names both exports, the undefined semantics, the producer's call shape, and that no shape's acceptance changes — accurate to the code. The delta is a comment and needs no changeset change. Check Changeset: success.

Clause-②: yes — present in the PR body, the changeset, and the claim; correct, since @objectstack/spec/data is a published entry (package.json exports["./data"]) and this PR widens it.

③ Boundary flags

Dev open_questions: none. Dev deviations, each judged:

  • Model-free commit trailer (Co-authored-by: Claude) because pre-push refuses a model id — answered: repo rule (AGENTS.md) over the harness reminder.
  • Zero label writes; skip-changeset not applied because a changeset ships — answered: correct, and the dispatch named no label.
  • Read cloud's sharing-model.ts via REST after a read-only add_repo — answered: a read to confirm the producer's call shape; nothing written to cloud.

Dev out_of_scope_findings, judged:

  • plugin-security resolveCbpRelation keeps its own master reading (carrier: none) — accepted as out of scope: the triage direction names only the parse and the lint; the lint's doc-comment at the head says so explicitly. Not escalated.
  • cloud's defaultSharingModel counts required !== false (omitted → master), which ObjectSchema refuses as master-not-required — correctly routed to the cloud seat (cloud#2865); adopting export 1 is cloud's change.

Reviewer findings:

  • The earlier record's one finding (stale "Internal: not re-exported" comment in object.zod.ts) is closed by the delta; no stale copy of the old claim remains in the files this PR touches (conversions/refused-shapes.ts:47 describes that module's own internal status, which is still true).
  • The brief's note says "two new public exports"; the diff publishes two functions and their two types (four api-surface entries). The types are the functions' signature, not extra surface, and the changeset names them. No action.
  • The PR body's claim "all lint findings, paths and texts are unchanged" is verified on the diff (the only lint source change is the one filter expression plus a doc-comment); it is not taken from the dev's report.

Check-runs on 7b2581304 (read once): all completed, none in progress, none failed. Success — Build Core; Test Core and shards 1/6–6/6; TypeScript Type Check with Type Check · workspace, · source gates, · consumer gates, · debt ledger; Lint & Repo Gates with Lint · ESLint, · PM dispatch-gates, · repo & tooling gates, · docs & code guards, · CI, release & engine gates, · scoped · tooling self-tests, · scoped · slot lookup & corpus, · scoped · query-options erasure; Check Changeset; Check PR Size; Check Documentation Links; Governed Surface Queue Guard; Spec property liveness; Flag docs affected by code changes; Auto Label; Dogfood Regression Gate; filter; the three claim/single-writer guards (same issue, same single-writer path, card claims this branch) and Part-of guard. Skipped (path-filtered / opt-in) — Build Docs, Dogfood Verify CLI, Console Pin Gate, Temporal Conformance, Dogfood shards, Packed-tarball smoke. Every derived gate family the card's dispatch names has a green conclusion on this head.

Implemented-by: claude/issue-23272-export-cbp-master-predicate
Reviewed-by: session_01D6F9axfoPzR7t8G1J7Ytzz

VERDICT: PASS

Adopted by domain:spec seat 3 (#18883), baozhoutao, session session_01D6F9axfoPzR7t8G1J7Ytzz, at 2026-10-14T14:29Z: rendered by an isolated read-only reviewer at CONTRACT_REVIEW_TIER; the seat measured the delta itself (git diff d88df525c 7b2581304: one comment, object.zod.ts:25-27).


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 14, 2026 14:31
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 14, 2026
Merged via the queue into main with commit 5547ad4 Oct 14, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-23272-export-cbp-master-predicate branch October 14, 2026 14:59
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 protocol:data size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec: export the controlled_by_parent master predicate, so a producer does not restate ADR-0055 §1's exactly-one-master rule

2 participants