Skip to content

feat(spec,plugin-security,lint): sharing.controlledByParent.ownerMayEdit — a detail row's owner updates it with read on the master - #26005

Merged
os-zhuang merged 7 commits into
mainfrom
claude/project-thread-h906g2
Oct 26, 2026
Merged

os-zhuang merged 7 commits into
mainfrom
claude/project-thread-h906g2

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steedos · project thread
Fixes #25760

Clause-②: yes (widening) — a detail row's owner now passes the master-detail write gate with READ on the master, on an object that declares the opt-in; everyone else, and every other shape of write, still needs master EDIT. The default is unchanged.
Metadata-protocol: added (ObjectSchema.sharing.controlledByParent.ownerMayEdit, a per-object opt-in read only under sharingModel: 'controlled_by_parent')
Ruling-ref: 6398566164 (letter 1)

What changes

ADR-0055 §3 had one rule for every write of a controlled_by_parent detail: EDIT access to the master record. Salesforce's Activities under OWD = Controlled by Parent let the owner of an activity edit it while merely reading its account; the platform could not express that, and an authored owner_id == current_user.id update policy on the detail could not either, because the detail's own policies compose AND with the master gate (step 2.7 then 2.8), so such a policy only narrowed the master's editors.

  • Spec (packages/spec/src/data/object.zod.ts, object.form.ts, liveness ledger, generated authorable surface). ObjectSchema gains sharing: { controlledByParent: { ownerMayEdit: boolean } }, both blocks closed (strictObject, with guidance for the near-miss spellings). ownerMayEdit: true on an object that is not a sharingModel: 'controlled_by_parent' detail is refused at parse, one custom issue at sharing.controlledByParent.ownerMayEdit, with the prescription. The reader controlledByParentOwnerMayEdit(schema) (@objectstack/spec/data) answers true only for the parsed literal and fails closed on every other shape.
  • Gate (packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts, security-plugin-cbp-relation.ts, security-plugin.ts). CbpRelation carries ownerMayEdit, resolved on the same registered schema the relation came from, and only when that schema carries the owner_id column (the registry's injected per-record owner under ownership: 'user', the default). In assertControlledByParentWrite, before the §3 chain walk, a by-id update on an opted-in detail by the caller whose userId equals the stored row's owner_id is admitted when the caller can READ the master row, asked of the read door under the caller's own context (readRowById through readUnlessRefused, the same probe the by-id pre-image gate uses; a hidden row and a declared refusal are one answer, a store fault propagates). A body that re-points the master reference still asks EDIT on the master it names. ISecurityService.checkControlledByParentWrite answers the same way, since it runs the same check.
  • Lint (packages/lint/src/validate-security-posture.ts, rule-explanations.ts, index.ts). New advisory arm security-controlled-by-parent-owner-policy-without-opt-in (warning): an update or all row-level policy whose using or check keys a field on the caller (FIELD == current_user.id, either operand order, the SQL = spelling the compiler bridges, record.FIELD too) on a controlled_by_parent detail that has not declared the opt-in. Judged by shape on the canonical CEL parse; a predicate that does not parse is the parse rule's. os explain carries the entry. warning, not error, because validateSecurityPosture also runs at the runtime publish doors, where an error refuses the write, and a narrowing can be meant.
  • ADR-0055 amended in place (status line, §3 rule line, a final Amendment section: ruling, gap, what the opt-in does, out of scope, fail-closed boundaries, unchanged). Governed surface, Tier H.

Scope: update only — stated as the ruling asks

The ruling names update. This PR admits the owner on a by-id update and nothing else:

  • insert: no stored row to own; the master is still asked for EDIT.
  • delete: not the ruled verb; still asked for EDIT on the master.
  • predicate update (multi: true): judges every master of the rows it selects and has no single row whose owner could be asked; unchanged.
  • delegated write (on-behalf-of): keeps the master-edit rule on both passes, as ADR-0090 D10 keeps both floors for every other relief.
  • A row with no owner admits nobody; an opt-in on a detail whose schema has no owner_id column (ownership other than 'user') is inert and reported once per object at warn by the relation resolver (the parse cannot refuse that half: the injected-column plan lives in a module that imports object.zod.ts).

Insert and delete can be added later under the same key if the maintainer rules so; nothing here forecloses it.

维护者速读(草稿)

改了什么

给 controlled_by_parent(访问随主记录)的从对象加了一个按对象声明的开关 sharing.controlledByParent.ownerMayEdit: true。打开后,从记录的所有者(owner_id 是自己)只要能读主记录,就能按 id 修改自己这条从记录;其他人、以及新增/删除/按条件批量改/代办写入,仍然要求对主记录有编辑权。不开这个开关,一切照旧。os lint 新增一条提示:在没开开关的从对象上写了 owner_id == current_user.id 的更新策略,会提醒作者这条策略永远放不进所有者。ADR-0055 §3 同步修订。

为什么改

裁决 6398566164(第 1 条)。对应 Salesforce 的"活动随父记录控制,但活动所有者始终能编辑自己的活动"这一常见业务形态:销售能看客户、不能改客户,但要能改自己名下挂在客户上的任务/义务。原来平台表达不了,作者自己写行级策略也无效(策略只会收窄,不会放宽主记录门)。

风险与代价(含回滚)

  • 放宽只在三件事同时成立时发生:对象显式声明了开关、调用者就是该行的 owner_id、调用者能读主记录。默认值不变,存量对象不受影响。
  • 守住的边界都有测试钉住:非所有者、无所有者的行、读不到主记录的所有者、新增、删除、批量改、代办写、把行挪到另一个主记录下,全部仍被主记录门拒绝。
  • 回滚:删除对象上的这一个键即可恢复原行为;代码层面整条逻辑是写门里一个独立的、先于原链路的分支,去掉分支即回到 §3 原规则。
  • 成本:一次 sys_-级读探针(读主记录)只在上述三件事成立时才发生。

席位意见

(留空)

你要做的

  1. 审阅 docs/adr/0055-master-detail-controlled-by-parent.md 的修订段落(受管面,Tier H),确认"只放宽按 id 更新、不含新增/删除"的范围与裁决一致。
  2. 批准后由席位落地;若希望新增/删除也纳入开关,请在卡上追加裁决,本 PR 不预设。

Acceptance notes

  • Owner = the platform's injected per-record owner owner_id (the sharing service's OWNER_FIELD, the "My records" scope), not created_by: the ruling says "the detail row's own owner", and the platform has one owner concept.
  • The master READ probe runs under the caller's own context through the engine's read door, so it is the answer a direct read of the master gives (object grant, row-level and sharing scope, and the chain for a master that is itself a detail). For a detail whose master the caller cannot read, the by-id pre-image already answers not-found before the gate; the suite pins that refusal too.
  • The detail's own object-level update grant and its own row-level policies (step 2.7) run before the leg, unchanged; an authored owner_id == current_user.id policy on an opted-in detail now decides as authored.
  • The lint test's structural meta-guard (validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017) gained the new reads (ps.rowLevelSecurity, a policy receiver against PermissionSetSchema.rowLevelSecurity[], the CEL AST receivers excused by name) and the push-site count moved 17 → 18 with a corpus fixture.
  • Out of scope, noted not filed: content/docs/permissions/*.mdx do not yet mention the opt-in (the generated spec docs do, through the key's describe); the owner-column half of the opt-in is reported at runtime rather than refused at parse (documented in the ADR amendment and the gate).

Verification

Head dece7114. Local verification, all under the shared verify lock where it is a build or a suite (the three affected packages, their dependency closure built first with pnpm --filter '@objectstack/plugin-security^...' --filter '@objectstack/lint^...' build, exit 0):

  • pnpm --filter @objectstack/spec test — Test Files 722 passed | 1 skipped (723), Tests 21899 passed | 1 skipped | 1 todo. typecheck exit 0.
  • pnpm --filter @objectstack/plugin-security test — Test Files 224 passed (224), Tests 4700 passed | 45 skipped. typecheck exit 0.
  • pnpm --filter @objectstack/lint test — Test Files 164 passed (164), Tests 7363 passed. typecheck exit 0.
  • The new suites on their own: spec parse pins 14/14, plugin-security rig 17/17, lint arm + the posture suite + rule explanations 206/206.
  • Ablation of the gate leg (rel.ownerMayEdit short-circuited to false, then the file restored from git): 4 of 17 rig pins red — the owner's admitted update, the delegated case's control, the body naming the row's own contract, and checkControlledByParentWrite answering allow; the 13 refusal pins stayed green, as they must (the leg adds no refusal of its own).
  • pnpm --filter @objectstack/spec check:generated exit 0 after regenerating the five artefacts it proved stale (api-surface, export-origins, docs, strictness-ledger counts, liveness counts); check:liveness, check:api-surface, check:authorable-surface, check:docs exit 0.
  • Derived gates (node scripts/pm/dispatch-gates.mjs --commands, 124 families for this change set): 122 run, every one exit 0, each exit code captured before any pipe; --ran reconciliation exit 0 with 0 UNRUN. 2 NOT MEASURED locally, both a recorded exit 3 (prerequisite: the whole farm's dist/, which this seat did not build): pnpm check:dual-build-cjs-loads, pnpm check:i18n — CI's check-runs are the verdict for those two.

Four pins outside the new suites moved with this diff, each a one-line or typed change, none a behaviour change: the two compose-stacks pins list sharing as the config object it is; the lint barrel-export pin wants the rule id on one line; the #5017 meta-guard table gained the arm's reads.


Generated by Claude Code

zhuangjianguo and others added 2 commits October 26, 2026 07:12
…dit — a detail row's owner updates it with read on the master

WIP: spec key with parse refusal off a detail, the owner leg of the
controlled-by-parent write gate, the lint arm for an owner-keyed update
policy on a detail that has not opted in, the ADR-0055 §3 amendment and
the liveness ledger rows. Tests, generated spec artefacts and the
changeset follow.

Claude-Session: https://claude.ai/code/session_01XT1qCAHDq5w7zUftxENNeE
Co-authored-by: Claude <noreply@anthropic.com>
…MayEdit — the opt-in parse, the owner leg of the write gate, the lint arm; regenerated spec artefacts; changeset

- spec: the parse pins (the opt-in only on a controlled_by_parent detail, the
  reader fails closed); the two compose-stacks pins gain `sharing` as the
  config object it is; api-surface, export-origins, docs, strictness-ledger
  counts and liveness counts regenerated from the built dist.
- plugin-security: the owner-may-edit rig over the real SecurityPlugin and
  ObjectQL (SqlDriver, in memory): the owner who reads the master is admitted
  and the row changes; a non-owner, an unowned row, an owner who cannot read
  the master, a delete, an insert, a predicate update, a delegated update and
  a body re-pointing the master are refused by the master gate; a detail
  without the opt-in refuses its owner as before; an opt-in on a detail with
  no owner column is reported once; checkControlledByParentWrite answers
  allow for the owner and deny otherwise.
- lint: the owner-policy arm's cases; the #5017 meta-guard table gains the
  arm's reads (ps.rowLevelSecurity, a policy receiver, the CEL AST nodes);
  the rule id constant on one line for the barrel pin.

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

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

37 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 0a6948210cc96a95cc568b30193895e157968c01.

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

What this run could not see
  • 6 changed file(s) yielded no anchor (packages/lint/src/index.ts, packages/spec/api-surface/data.json, packages/spec/authorable-surface/data.json, …) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: current_user (literal, 37 pages)
  • 6 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 — 149 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 0a6948210cc96a95cc568b30193895e157968c01 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 0a6948210cc96a95cc568b30193895e157968c01

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

…nd author its translation leaves

CI on dece711 was red in three places, every one a consequence of the new
`ObjectSchema.sharing` block (#25760) that the first round did not carry:

- metadata-core's OBJECT_REFERENCE_POSITIONS did not classify `sharing`, so
  its "classify each new ObjectSchema key" guard went red (Test Core 8/8).
  Classified as `keep`: a block of booleans that names no field.
- The platform-objects translation bundles had drifted from the object form
  (`pnpm check:i18n`, Type Check · consumer gates). Regenerated, and the six
  new leaves (`sharing`, `sharing.controlledByParent` and
  `sharing.controlledByParent.ownerMayEdit`, a label and a help text each)
  authored in zh-CN, ja-JP and es-ES, so their provenance rows drop as the
  #19403 regime asks of every metadata-form leaf. The two leaf-count pins
  of that regime move with the form: 107 → 113 collapsed-section leaves
  (`advanced` 96 → 102) and 679 → 682 row labels authored in all three
  locales.

Claude-Session: https://claude.ai/code/session_01XT1qCAHDq5w7zUftxENNeE
Co-authored-by: Claude <noreply@anthropic.com>
Resolves the one conflict: the lifecycle echo-decisions positive-control pin reads 683 (main's 680 for the dataset duration measure, plus the three sharing row labels of #25760).

Claude-Session: https://claude.ai/code/session_01XT1qCAHDq5w7zUftxENNeE
Co-authored-by: Claude <noreply@anthropic.com>
Discharges the regeneration the merge commit deferred: content/docs/references/data/object.mdx, regenerated with gen:schema and gen:docs from the merged tree (main's dataset-form additions beside the sharing opt-in of #25760). check:generated and check:docs exit 0; the translation bundles re-extracted byte-identical.

Claude-Session: https://claude.ai/code/session_01XT1qCAHDq5w7zUftxENNeE
Co-authored-by: Claude <noreply@anthropic.com>
…vel properties

The /meta/types control pins the served property count per type. `object` moved 47 → 48 when it gained `sharing` (#25760, the per-object sharing opt-ins of the ADR-0055 §3 amendment); the pin and its history line move with it. Test Core (7/8) on e0883a0: 1 failed of 21557 before the move, 266 files green after.

Claude-Session: https://claude.ai/code/session_01XT1qCAHDq5w7zUftxENNeE
Co-authored-by: Claude <noreply@anthropic.com>
The BOUNDARY test in auth-gate.test.ts walks about 133k paths × 6 methods through the allowlist predicate. It takes about 1.3 s on an idle box and 5.4 s on a Test Core runner that carries core beside spec's test and test:repo tasks (PR #26005, shard 5, three runs in a row), where vitest's 5 s default refused a healthy enumeration. The explicit timeout is a ceiling on load, not a budget: no skip, no quarantine, no change to what the test asserts. The file is outside #25760's claim surface; the breach is stated in the os-dev report.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 07f1f087aabd8c394f99dc1b7197bcc167bab2ee
Local-runs: none
Scope: packages/spec/src/data/object.form.ts, packages/spec/src/data/object.zod.ts, docs/adr/0055-master-detail-controlled-by-parent.md + packages/plugins/plugin-security/src/security-plugin-cbp-write-gate.ts, packages/plugins/plugin-security/src/security-plugin-cbp-relation.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/lint/src/validate-security-posture.ts, packages/lint/src/rule-explanations.ts, packages/lint/src/index.ts, packages/spec/src/data/index.ts, packages/metadata-core/src/object-schema-fls-references.ts, packages/core/src/security/auth-gate.test.ts, .changeset/25760-controlled-by-parent-owner-may-edit.md, the three new test files (spec, plugin-security, lint). The rest of the diff (generated reference docs, strictness and liveness ledgers, api/authorable/export-origins surfaces, the four translation bundles and their two count pins, the metadata-protocol property-count pin, the two compose-stacks pins, the lint posture meta-guard) is the lane seat's ACCEPT at TIER_DEFAULT, read as a standalone PR of those files.

Inputs: card #25760 (body and all seven comments, ruling 6398566164 letter 1, the premise check 6409904490, the amended claim 6410093517, os-dev-reports 6411992806 / 6414737027 / 6417667323), PR #26005 (body, 35-file list, net diff against main), and the 48 check-runs on the head: every non-skipped run is success (Test Core 1/8 to 8/8, Build Core, Type Check workspace / consumer gates / source gates / debt ledger, Lint & Repo Gates and every scoped lane, Check Changeset, Governed Surface Queue Guard, Spec property liveness, the claim and single-writer guards); Lint UI, Test UI, Dogfood and Temporal lanes skipped by path filter. Judged against the AMENDED claim text.

① Derived judgments

Each accept-set or public-surface change the diff implies, with its verdict.

  1. ObjectSchema accepts a new optional top-level key sharing, a strictObject carrying one strictObject controlledByParent with one ownerMayEdit: boolean (object.zod.ts). RIGHT. Both blocks closed from the first release, guidance entries for the three near-miss spellings (sharingModel, publicSharing, a ownerMayEdit one level up); the spec test pins unrecognized_keys at sharing and at sharing.controlledByParent, and invalid_type for a string. A pure widening: no existing key moves, no existing value is refused.
  2. The parse refuses ownerMayEdit: true on an object whose sharingModel is not controlled_by_parent (refuseControlledByParentOwnerMayEditOffDetail, called from the same superRefine as refuseControlledByParentWithoutOneMaster; one custom issue at sharing.controlledByParent.ownerMayEdit, naming the model or no sharingModel, with the prescription). RIGHT. A refusal on a key that did not exist before narrows nothing an author could write on main; it is the ADR-0049 shape (no door reads the key off a detail). ownerMayEdit: false, an empty controlledByParent and an empty sharing are accepted on any object and read as off; harmless.
  3. New export controlledByParentOwnerMayEdit(schema: unknown): boolean from @objectstack/spec/data (export * from './object.zod' in data/index.ts; api-surface and export-origins moved by exactly that one symbol). RIGHT. Fail-closed on every unreadable shape (=== true only), does not read sharingModel (the caller's question), and is the ONE reader the parse, the gate (through resolveCbpRelation) and the lint share.
  4. objectForm gains a sharing composite beside sharingModel, nested controlledByParent composite, ownerMayEdit boolean leaf (object.form.ts). RIGHT. Same nested-composite shape the file already uses; authorable-surface gained data/Object:sharing; the four translation bundles carry the six leaves and the two count pins moved with them (CI-forced, lane seat's). One wording nit, not a defect: the controlledByParent helpText says the block is refused on any other sharing model, while the parse refuses only ownerMayEdit: true there (an empty block is accepted). The leaf's own helpText and the Zod describe state the rule exactly.
  5. CbpRelation.ownerMayEdit (security-plugin.ts, resolved in resolveCbpRelation): true only when the relation resolves AND the schema reader answers true AND the registered field map carries owner_id. RIGHT. Resolved on the same schema the relation came from; an opt-in without the owner column resolves false and is reported once per cbpRelCache generation at warn (reportOwnerMayEditWithoutOwnerColumn, host method and relation-module body, the reportCbpMasterUnresolved pattern). The plugin-security rig pins omd_note (ownership: 'none') refused by the master gate and reported exactly once across two writes.
  6. The owner leg in assertControlledByParentWrite (cbp-write-gate.ts), placed after the masterId == null branch and before the §3 chain walk, admitting the write when ALL hold: operation === 'update'; detailRow !== null (set only in the single-id branch, after extractSingleId; the predicate multi: true path and the insert path leave it null and the predicate path returns before the leg); rel.ownerMayEdit; !opCtx.context.onBehalfOf.userId (the same envelope the master-floor exclusion reads, shared by both passes of a delegated write, so the delegator's pass with its own context cannot slip through); context.userId equals the stored row's owner_id (read as system above, so the caller cannot supply it); and readUnlessRefused around this.readRowById(rel.master, masterId, context) returns a row. RIGHT. readRowById is ql.findOne(master, { where: { id }, context }) under the caller's own context, i.e. the engine's read door with this plugin's read derivation and the sharing middleware, so the probe is the answer a direct read of the master gives; a declared 4xx and a hidden row are one answer (refused / null, readRowById swallows engine failures into null, so a store outage is indistinguishable from an absent row at every gate that probes with it #7505 semantics in readUnlessRefused), a store fault propagates. On admission the leg still runs askBodyNamedMaster([masterId]), so a body that re-points the master asks EDIT on the master it names and a body naming the stored master is not asked again; masterNotWritable (plugin-security: the controlled_by_parent detail write gate may not hold one more value of a master reference to ADR-0055 §1 and §3 (reach not measured; detail withheld under the disclosure ruling) #25095) and the null-master freeze (A missing required master-detail parent still answers 422 MISSING_REQUIRED_FIELD with no fields[] and a [Security] message — while the same field, present-but-unresolvable, answers 400 VALIDATION_FAILED with fields[] (#7474 residual, 17.0.0 GA) #8688 / Freeze note on assertControlledByParentWrite — the guard is the sole enforcement for three authorable master-reference shapes until the #8772 ramp completes #9137) are judged before the leg, unchanged. Every other shape (insert, delete, predicate update, delegated write, non-owner, unowned row, unreadable master, no opt-in) falls through to the §3 walk and carries the walk's own envelope, so no new sentence reaches a response body. The 17-pin rig over the real SecurityPlugin + ObjectQL + SqlDriver + SharingService pins each of those boundaries plus two admitted controls (master editor who owns nothing; owner on an opted-in detail), and the dev's ablation reported 4 red / 13 green, the expected split.
  7. ISecurityService.checkControlledByParentWrite and security/explain follow the leg — the served member builds opCtx = { object, operation: 'update', options: { where: { id } }, context } and runs the same assertControlledByParentWrite on both principals; explain-engine.ts consults it for by-id writes. RIGHT; the rig pins allow for the owner and deny for the same grants without ownership and for the non-opted detail.
  8. Lint: new advisory arm security-controlled-by-parent-owner-policy-without-opt-in (warning), its constant exported from the barrel, its os explain entry (validate-security-posture.ts, rule-explanations.ts, index.ts). RIGHT. Subject: an enabled update / all row-level policy naming a controlled_by_parent object of the stack whose master resolves (owdOf + the lint's own resolveCbpRelation) and whose controlledByParentOwnerMayEdit is false; judged on using and check separately by shape on the canonical CEL AST (parseCelToAst(sqlPredicateToCel(...)), both in @objectstack/formula, already a declared dependency of @objectstack/lint): FIELD == current_user.id either order, bare or record.FIELD, under && / ||; a non-parsing predicate keys on nothing (the parse rule's). warning is the right tier: authoring-rules.ts splits findings into errors (refuse) and advisories, so the runtime publish doors do not refuse the shape, and a narrowing can be meant. The validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017 receiver meta-guard, the push-site count 17 to 18 and the reachability corpus fixture moved with it.
  9. ADR-0055 amended in place (status line, the §3 rule line marked, a closing Amendment section). RIGHT and faithful to the diff: it records letter 1, option 2 not chosen, the four gate conditions, the three things the leg does not change, the out-of-scope verbs with their reasons, the fail-closed boundaries (owner-column half reported at resolution because injected-system-columns.ts imports object.zod.ts, verified), and what stands. Every symbol it cites resolves (assertControlledByParentWrite, resolveCbpRelation, masterGateCoversOperation, reportOwnerMayEditWithoutOwnerColumn, the §2 "all AND-ed" line, ADR-0139 D6, ADR-0090 D10). Governed surface, Tier H: this record does not merge it; a GOVERNED_APPROVER's word does.
  10. Metadata-core OBJECT_REFERENCE_POSITIONS.sharing: keep and the /meta/types object property count 47 to 48: RIGHT, both forced by the new key, both what the key is (a block of booleans naming no field).
  11. Scope matches the ruling. The ruling names update; the diff admits a by-id update only and states insert, delete, predicate update and delegated writes stay on master EDIT, with none foreclosed. The card's "Not asked" items hold: the default is unchanged (the omd_task pins), and os lint keeps flagging the dead owner policy.

Nothing in the diff widens beyond the ruling, and nothing narrows an existing accept set.

② Semver level

Clause-②: yes (widening)

  • Changeset .changeset/25760-controlled-by-parent-owner-may-edit.md: @objectstack/spec, @objectstack/plugin-security, @objectstack/lint all minor, carrying Clause-②: yes (widening), Metadata-protocol: added (...) and Ruling-ref: 6398566164 (letter 1) as line-initial declarations identical to the PR body's. RIGHT: a new authorable key is added, and added takes at least minor on a package the diff moves (spec moves); yes (widening) is the correct arm for a gate that admits more callers and refuses nothing it admitted before; no (narrowing) arm, no BREAKING banner owed, no ADR-0087 disposition owed, no skip-changeset.
  • @objectstack/metadata-core and @objectstack/platform-objects also move published source (one keep classification row; regenerated translation bundles) and are not listed. Not a defect: every publishable package sits in the one fixed group, so the minor bump reaches them in lockstep, and the level axis of check-changeset-no-major asks for at least one moved package graded minor or above (Check Changeset: success). @objectstack/core and @objectstack/metadata-protocol move test files only.

③ Boundary flags

The dev's current os-dev-report is 6417667323; open_questions: []. Every deviation and out-of-scope finding, answered:

  1. Claim surface widened by CI reds (metadata-core classification; the four translation bundles with six hand-translated leaves and two count pins; the metadata-protocol served-schema pin). ANSWERED: each is a gate-forced consequence of the new key, each is in the amended claim 6410093517, each matches the diff, none is a design widening.
  2. BREACH: packages/core/src/security/auth-gate.test.ts, an explicit 30 s timeout on the BOUNDARY test, seat ruling A. ANSWERED, accepted. The hunk adds a five-line comment naming the measurement and the 30_000 argument; no assertion, no skip, no quarantine, no core source moves in this diff, so the PR cannot be the cause of the enumeration's cost (about 133k paths times 6 methods, 1.3 s idle, 5.4 s on the loaded shard across three runs). Test Core (5/8) is success on this head. The dev's out-of-scope note that a cheaper enumeration is core's owner's call is the right carrier for the follow-up; nothing to escalate.
  3. The shard-5 failure could not be named from the earlier runs (log tail cap, blob host denied). ANSWERED: process, not product; the head's check-runs are the verdict and are green.
  4. The head's gate sweep ran in two runners; the first-round runner ran unlocked. ANSWERED: local runs are not this record's input; the derived families are answered by Lint & Repo Gates, the Type Check and Test Core lanes, all success.
  5. One single-file vitest run outside the lock. ANSWERED: same as 4.
  6. Out-of-scope findings: (a) content/docs/permissions/*.mdx do not yet mention the opt-in while the generated reference pages do, noted not filed: ACCEPTED as docs follow-up, no gate owes it. (b) The owner-column half of the opt-in is reported at runtime rather than refused at parse, because of the injected-system-columns.ts to object.zod.ts import cycle: ACCEPTED, the boundary is fail-closed (the opt-in resolves to false, the §3 rule holds, one warn per object), the ADR amendment and the gate docblock both state it, and the rig pins it. (c) auth-gate enumeration cost, (d) teardown-shape self-test on a shallow clone, (e) job-log tail cap, (f) reaped background process: seat and tooling notes, nothing for this record to escalate.

No flag is left open. The PR stays a draft awaiting the Tier H approval the ADR amendment owes; this record is the contract-tier review of record for the head above and merges nothing.

Implemented-by: claude/project-thread-h906g2
Reviewed-by: session_01RyMY3prh9GRva1Tt37Di3i

VERDICT: PASS

@objectstack-fleet

objectstack-fleet Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

维护者速读

改了什么

这个 PR 给"访问随主记录"(controlled_by_parent)的从对象加了一个按对象声明的开关:sharing.controlledByParent.ownerMayEdit: true。打开后,从记录的所有者(owner_id 是本人)只要能读主记录,就能按 id 修改自己这条从记录。其他人照旧要求对主记录有编辑权,新增、删除、按条件批量改、代办写入也都照旧。不开这个开关,行为与现在完全一样。

os lint 新增一条 warning:从对象没开开关、却写了 owner_id == current_user.id 的更新策略时,提示作者这条策略放不进所有者。ADR-0055 §3 已同步修订。

为什么改

依据裁决 6398566164 第 1 条。这对应 Salesforce 里"活动随父记录控制,但所有者能改自己的活动"的形态:销售能看客户、不能改客户,但要能改自己名下挂在客户上的任务。原来平台表达不了这一点;作者自己写行级策略也无效,因为行级策略只会收窄,不会放宽主记录门。

风险与代价(含回滚)

  • 只有三件事同时成立才放宽:对象显式声明了开关,调用者是该行的 owner_id,调用者能读主记录。默认值不变,存量对象不受影响。
  • 边界都有测试钉住:非所有者、无所有者的行、读不到主记录的所有者、新增、删除、批量改、代办写、把行挪到另一条主记录下,全部仍被主记录门拒绝。
  • 主记录的读探针以调用者自己的身份走引擎读门,不是系统级读,所以结论与调用者直接读主记录一致。这一处校正了草稿里"sys_ 级读探针"的写法。
  • 回滚:删掉对象上这一个键即恢复原行为。代码层面,它是写门里一个独立分支,去掉即回到 §3 原规则。

席位意见

建议批准。契约审查 PASS(6417851199),席位核对见下方。这是纯放宽:没有任何现有写法被收窄,新键从第一版起就是封闭结构,CI 48 项 0 失败。

只有一个不阻塞的小瑕疵:表单上 controlledByParent 的帮助文字说"其他共享模型下整块被拒",实际只拒 ownerMayEdit: true。留待之后顺手修,不在本 PR 改。

你要做的

审阅 docs/adr/0055-master-detail-controlled-by-parent.md 的修订段落,同意就在本 PR 点 Approve。之后由席位落地。

席位核对

ACCEPT · domain:spec seat 2 · 2026-10-26T14:00Z · head 07f1f087aabd8c394f99dc1b7197bcc167bab2ee

  1. Shape. Draft, base main, Fixes #25760. 34 files, +1432/−18. GitHub reads it mergeable: clean against the current main.
  2. Governed. check-governed-merges --pr 26005 says GOVERNED, Tier H, on one path: docs/adr/0055-master-detail-controlled-by-parent.md. No seat flips this PR ready, enqueues it, or arms auto-merge.
  3. Gates (check-runs pinned to this head). 48 check-runs, 0 failed. check-expected-skips --pr 26005: OK, with 7 skipped, every one in main's roster.
  4. Contract review. PASS at CONTRACT_REVIEW_TIER on this head, comment 6417851199. The face is schema: object.zod.ts and object.form.ts, plus the ADR-0055 hunk on the rules face. It was judged against the amended claim 6410093517.
  5. Remainder at TIER_DEFAULT (seat-read).
    • The generated reference pages, the api-surface, authorable-surface and export-origins entries, and the liveness and strictness ledgers each move by the one new key or the one new export, as the record names.
    • The four translation bundles add the six new form leaves; their two count pins and the metadata-protocol property-count pin move with them.
    • auth-gate.test.ts gains an explicit 30 s timeout on the BOUNDARY test only. This is test-only with no assertion or skip change, the surface breach ruled A on the claim.
  6. Prose checked against the diff.
    • Changeset, Spec bullet: the two closed blocks, the parse refusal off a controlled_by_parent detail at sharing.controlledByParent.ownerMayEdit, and the fail-closed reader all match object.zod.ts.
    • Changeset, Gate bullet: the leg admits a by-id, non-delegated update by the stored owner_id with READ on the master; insert, delete, predicate updates and delegated writes keep the master-edit rule; a re-point asks EDIT; an unowned row admits nobody. All match assertControlledByParentWrite as the record traced it.
    • Changeset, "reported once per object at warn": security-plugin-cbp-relation.ts logs that warning from the relation resolver, which caches per generation. Accepted.
    • Changeset, Lint bullet: the rule id and its warning severity match validate-security-posture.ts and rule-explanations.ts.
    • Reference pages: the new sharing row restates the schema's description verbatim.
    • Non-blocking nit, carried from the record: the form's controlledByParent helpText says the block is refused on other sharing models, but the parse refuses only ownerMayEdit: true there. Fix it in a later touch, not in this PR.
  7. Open questions (os-dev-report 6417667323). open_questions is empty. The out-of-scope notes (the permissions docs, the owner-column half at runtime) are accepted as documented and fail-closed.
  8. Landing. This waits for an APPROVED review on this head from a GOVERNED_APPROVERS account (os-zhuang or hotlong), after which the owning seat lands it. Fixes #25760 closes the card on merge.

@os-zhuang
os-zhuang marked this pull request as ready for review October 26, 2026 15:00
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat 2 · landing after an authorized approval: os-zhuang APPROVED (review 5654114202) on head 07f1f087. Pre-landing checks on this head: check-governed-merges GOVERNED Tier H, approval present; mergeable_state: clean; check-expected-skips OK (11 skipped, all in main's roster); every check-run complete with 0 failures. The ACCEPT (6417897066) and contract review PASS (6417851199) stand. needs-user-decision cleared, marked ready, auto-merge armed.

@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 26, 2026 15:00
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 26, 2026
Merged via the queue into main with commit 5e113b3 Oct 26, 2026
57 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-h906g2 branch October 26, 2026 15:38
os-project-manager pushed a commit that referenced this pull request Oct 26, 2026
…a direct grant (#24757)

controlled-by-parent-owner-may-edit.test.ts landed on main (#26005) after this
branch's base. Its delegator reached omd_requester through a sys_user_position
row named after the set, the fold this PR retires, so the delegated-update pin
refused at the on-behalf-of check instead of the master gate. The row is now a
sys_user_permission_set grant, the same rebinding the chain-middle-master suite
received; every expectation is unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8LYP8UCJURxbFWZqnywyD
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/xl tests tooling

Projects

None yet

3 participants