Skip to content

fix(lint): the controlledByParent owner arm prescribes ownerMayEdit only for an owner_id-keyed policy - #26658

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-j9djvl
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/project-thread-j9djvl

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26591

Clause-②: no (lint advice is not a contract face; no accepted metadata shape moves)
Metadata-protocol: none

维护者速读

  • 改了什么:os lint 的 security-controlled-by-parent-owner-policy-without-opt-in 只在策略按 owner_id == current_user.id 写、且明细表确有 owner_id 列时,才建议加 ownerMayEdit: true;按别的人员字段(如 hotclm 的 owner)写的策略,不论加没加开关都会提示,并改为建议「把负责人放进 owner_id」,不再建议这个开关。
  • 为什么改:运行时的写入门只认行上存的 owner_id。以前 lint 对任何人员字段都建议加开关,hotclm 照做后 lint 不响了,但负责人照样被 403 拒绝,等于 lint 在误导。
  • 风险与回滚:只改 lint 的提示文字和触发条件,不动运行时权限门,也不动任何元数据契约;回滚就是 revert 本 PR。
  • 先看:packages/lint/src/validate-security-posture.ts(新函数 carriesCbpOwnerColumn、pushCbpCallerPolicyOffOwnerColumn 和规则循环里的 admittable 一行);packages/lint/src/validate-security-posture-owner-may-edit.test.ts(新的 controlledByParent.ownerMayEdit admits only owner_id, but its lint arm prescribes it for any FIELD == current_user.id policy — an app whose accountable person is its own user field stays refused #26591 用例组);packages/lint/src/rule-explanations.ts。
  • 两分钟验证:pnpm --filter @objectstack/lint exec vitest run src/validate-security-posture-owner-may-edit.test.ts,预期全部通过,其中「an other-field policy with the opt-in: still flagged」一条证明加了开关的 owner 策略仍会被提示。
  • 需要你做的:无,席位落地。

What changes

sharing.controlledByParent.ownerMayEdit: true admits one caller past a controlled_by_parent detail's master-write gate. That caller is the user in the stored row's owner_id (assertControlledByParentWrite), and only on a detail whose registered schema carries that column (resolveCbpRelation resolves the opt-in false otherwise). The lint arm prescribed the opt-in for any FIELD == current_user.id update policy. This PR narrows the advice to the shape the runtime honours. The runtime gate does not change.

detail predicate keys the caller on before after
carries owner_id, no opt-in owner_id (alone or beside another field) flagged, opt-in prescribed same
carries owner_id, opted in owner_id (alone or beside another field) silent same
carries owner_id, no opt-in only another user field (owner) flagged, opt-in prescribed flagged, the verdict says the opt-in admits only the stored owner_id, and the hint says to key the accountable person on owner_id. The hint never names the opt-in
carries owner_id, opted in only another user field (owner) silent flagged, as above, and the verdict names the declared opt-in
no owner_id (ownership: 'business_unit' | 'org' | 'none', no own owner_id field) anything, owner_id included flagged and opt-in prescribed (no opt-in), or silent (opted in) flagged, the verdict says it is "a column the detail does not carry", and the hint says to give the detail an owner_id column. The hint never names the opt-in
  • packages/lint/src/validate-security-posture.ts. carriesCbpOwnerColumn(obj) answers whether the detail carries owner_id the way the runtime does. It reads the spec's injection plan (injectedColumnsFor, resolveInjectedSystemColumns) or a field the author declared under that name. The arm keeps { optedIn, ownerColumn } per resolving detail instead of a set of details without the opt-in. A predicate whose keyed fields include owner_id on a detail that carries it keeps today's behaviour. Any other caller-keyed predicate goes to the new push site, pushCbpCallerPolicyOffOwnerColumn, under the same rule id and the same warning tier. callerIdentityKeyedFields keeps its shape: it already returns field names, and this arm is its only caller.
  • packages/lint/src/rule-explanations.ts. The os explain entry now says that the opt-in reads only the stored owner_id, only on a detail that carries it. It gives a separate remedy for each case.
  • packages/spec/liveness/object.json. The ownerMayEdit evidence string described the arm as flagging "an owner-keyed update policy on a detail that has not declared ownerMayEdit". After the narrowing that undersells it, so it now describes the narrowed arm, and its verifiedAt moves to 2026-10-27. This is an evidence string, not a contract change. The object.zod.ts docblock near :3089 ("so the gate and the lint ask one question one way") is still true, because both read the opt-in through controlledByParentOwnerMayEdit, so it is unchanged.
  • Changeset: @objectstack/lint patch, plus @objectstack/spec patch because liveness/ ships in spec's files[].

Tests

  • validate-security-posture-owner-may-edit.test.ts adds a #26591 group on hotclm's shape (a controlled_by_parent detail with its own owner user field and using: owner == current_user.id):
    • owner_id policy without the opt-in: flagged, opt-in prescribed.
    • owner_id policy with it: silent.
    • owner policy without the opt-in: flagged with the owner_id remedy. The hint does not contain ownerMayEdit.
    • owner policy with the opt-in: still flagged.
    • Mixed owner || owner_id policy: prescribed without the opt-in, silent with it.
    • ownership: org | none | business_unit, with and without the opt-in: flagged with the owner-column remedy and no opt-in.
    • An author's own owner_id field on ownership: 'none': prescribed, then silent.
    • The fixture is key-legal under ObjectStackSchema.
  • validate-security-posture.test.ts meta-tests:
    • The receiver table excuses detail (the arm's own fold) and sharing (the opt-in's spelling inside the verdict string).
    • cbpDetails replaces cbpDetailsWithoutOptIn in the plumbing list.
    • The findings.push count goes from 18 to 19.
    • A reachability-corpus row drives the new push site with key-legal metadata.

Acceptance notes

Verification

All readings below are on head 816501243.

Lint package: tests, typecheck, scoped eslint
  • pnpm --filter @objectstack/lint test: Test Files 166 passed (166) · Tests 7395 passed | 5 skipped (7400). This run was on the tree before the corpus row and the detailName typecheck fix. The two touched suites re-ran on the final tree: Test Files 2 passed (2) · Tests 210 passed (210).
  • pnpm --filter @objectstack/lint typecheck (tsc --noEmit && pnpm check:test-typecheck): exit 0.
  • eslint --no-inline-config on the four touched lint files: exit 0.
  • pnpm --filter @objectstack/spec exec vitest run (for the liveness string): Test Files 810 passed (810) · Tests 23353 passed | 1 todo (23354) · VERDICT command-exit 0.
Ablation (one, of the narrowing)

node scripts/ablation-replace.mjs replaced const admittable = detail.ownerColumn && fields.includes(CBP_OWNER_COLUMN); with const admittable = true || (…); // ABL26591. That restores the old behaviour: every caller-keyed predicate takes the prescriptive path, and an opted-in detail is silent.

  • The mutation landed: anchor x1 to x0, replacement x0 to x1, blob f52007d93faf to 3b0a2d4cd9cb.
  • The two suites went red as expected: Tests 5 failed | 205 passed (210). The 5 failures are both other-field cases (without and with the opt-in) and the three no-owner_id cases (org / none / business_unit). Every owner_id case stayed green.
  • The restore was proven: the blob after restore equals HEAD (f52007d93faf), and git diff HEAD is empty.
  • The lint suites import ./validate-security-posture.js from src/, so no build sat between the mutation and the run.
Derived gates: 65 / 65, reconciled
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 65 commands. All 65 ran, every exit code was captured before any pipe, and all 65 exited 0.
  • --ran: ✓ dispatch-gates --ran: 65 derived famil(ies) accounted for — 65 run, 0 NOT-MEASURED.
  • Four commands first answered exit 3 (PREREQUISITE NOT MET) and were re-run after their prerequisite. Their second readings are the ones counted:
    • check-plugin-teardown-shape.mjs --self-test, after git fetch origin 621a4876 (the self-test's positive control).
    • check:docs-transcript-drift, check:lean-entry-closure and check:dual-build-cjs-loads, after pnpm --filter '@objectstack/lint...' --filter '@objectstack/objectql...' build.
  • Dogfood battery: not owed. The title has no !, and Clause-② has no (narrowing) arm.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5oX5j1RjbyiKQpdeiF


Generated by Claude Code

…for an owner_id-keyed policy (#26591)

The opt-in admits only the stored owner_id, on a detail that carries it.
A policy keyed on another user field (or on an owner_id the detail lacks)
is flagged with or without the opt-in, with the owner_id remedy and
never the opt-in. The runtime gate is unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5oX5j1RjbyiKQpdeiF
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 6 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/spec/liveness/object.json), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a76f811e0eb29aaab055e52d541c7203394ccf5f.

⛔ 6 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/spec/liveness/object.json) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 55 of 207 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 — 147 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 a76f811e0eb29aaab055e52d541c7203394ccf5f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a76f811e0eb29aaab055e52d541c7203394ccf5f

⚠️ 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 a76f811e0eb29aaab055e52d541c7203394ccf5f → 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

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-27T12:18Z · head 81650124 · #26591

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main, assignee os-project-manager.
    • The body carries Fixes #26591, Clause-②: no (…) and Metadata-protocol: none, all line-initial.
    • The changesets are an @objectstack/lint patch and an @objectstack/spec patch (the liveness evidence string ships in spec's files[]).
    • check-governed-merges --pr 26658 reads NOT governed, 268 changed lines. Lint advice is not an accepted or emitted value, so no at-tier review is owed.
    • The card's newest claim (6439634380) names this PR's branch.
  2. Diff against the claim's default arm (the seat read the whole non-test diff):
    • The arm now prescribes ownerMayEdit only where the runtime honours it: a predicate keying the caller on owner_id, on a detail that carries that column (injection plan via injectedColumnsFor, or an author-declared owner_id). This mirrors resolveCbpRelation.
    • Any other caller-keyed update predicate goes to a new push site under the same rule id and warning tier, whether or not the detail opted in. That covers another user field, or owner_id on a detail without the column. Its hint never names the opt-in, which removes the advice the runtime refused with a 403.
    • The os explain entry and the ownerMayEdit liveness evidence describe the narrowed arm. Nothing under packages/plugins/plugin-security/** changes, there is no new packages/spec/** key, and nothing under docs/adr/** changes.
  3. Tests:
    • A #26591 group covers every row of the body's before/after table, on hotclm's shape:
      • an owner_id or owner policy, each with and without the opt-in;
      • a mixed owner || owner_id policy;
      • ownership: org | none | business_unit;
      • an author's own owner_id on ownership: 'none'.
    • The meta-tests move with the code: the push count goes from 18 to 19, and a reachability-corpus row covers the new site.
    • The dev's ablation turned 5 red with every owner_id case green, and the restore was proven.
  4. Overlap: the open feat(spec): a report declares its period (dateRange) and the export of its table (exportOptions) #26628 and feat!: ownership queues and assignment rules (ADR-0142 phase 2) #26337 touch rule-explanations.ts on other entries. git merge-tree of each against this head is clean (seat's read at this stamp).
  5. Gates: at this stamp every check-run on the head reads success or skipped; none reads failed or in progress. Locally the dev reports:
    • 65 of 65 derived gates exit 0, and --ran reconciles them;
    • lint 7,395 tests green, with typecheck and eslint;
    • spec 23,353 tests green.
  6. Out of scope: ADR-0055 :176 says an owner-keyed policy without the opt-in "is flagged with the opt-in as the fix". After this PR that holds only for a policy keyed on owner_id. The order kept docs/adr/** out, so the seat files the ADR wording separately. Widening the runtime to a named owner field is the maintainer's open decision, and this PR does not touch it.

Generated by Claude Code

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