Skip to content

fix(plugin-sharing)!: a sharing rule on an object without record sharing is refused at save - #30953

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-lpcxlh
Nov 6, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-lpcxlh

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

domain:server#4 · os-elon-musk · session_016WV4cyFB75QwwCDu3S5GNZ

Fixes #30621
Clause-②: no (narrowing)
Metadata-protocol: none

维护者速读

  • 改了什么:在没有记录共享的对象上(如 sys_session、sys_account、sys_api_key)保存共享规则,现在两个保存入口(Setup 表单走的 /data 门,和 POST /api/v1/sharing/rules)都会拒绝,答 422 SHARING_NOT_ENABLED,句子点名对象并说明怎样为它开启记录共享;改对象的编辑同样拒绝。
  • 为什么改:以前这样的规则保存成功(201)、在 Setup 里显示为启用,但调和时每条授权都被拒、一条 sys_record_share 也不写,管理员以为生效了,其实什么都没共享。
  • 风险与回滚:收窄(BREAKING 横幅已加):以前能存的这类规则现在存不进。判定复用调和本身的 D7 惰性判定,两边不会不一致;有属主列的公开对象仍允许(外部成员仍能拿到授权)。已存的旧规则不迁移、不删除,仍可关闭。回滚即 revert。
  • 先看:① packages/plugins/plugin-sharing/src/sharing-service.ts 的 sharingRuleObjectRefusal(判定与 assertNotInertGrant 同源);② rule-hooks.ts 的 bindRuleObjectEnforcementGuard(更新只在换对象时检查);③ sharing-rule-service.ts 的 defineRule 新增检查。
  • 两分钟验证:pnpm --filter @objectstack/plugin-sharing exec vitest run src/sharing-rule-object-enforcement.test.ts,预期 9 个测试全绿。
  • 需要你做的:无。你已在席位线程的决策卡上点头(破坏性),由席位落地。

What changes

Before, an administrator could save a sharing rule (sys_sharing_rule) on an object no sharing gate consults. The save answered 201, the rule showed as active in Setup, and its reconcile then met the ADR-0111 D7 inert-grant guard (SharingService.assertNotInertGrant, SHARING_NOT_ENABLED) and wrote no sys_record_share, so the recipients saw nothing more than before.

Now both save doors refuse such a rule and save nothing:

  1. The verdict, from one place. SharingService.sharingRuleObjectRefusal(object) returns the refusal sentence, or null. It reads the same caller-independent verdict the grant pre-flight throws on (the new private objectInertReason, which assertNotInertGrant now calls too), so the save door and the reconcile cannot disagree about which objects are under enforcement. The sentence names the object, gives the D7 reason, and adds the remedy for that arm: a private or read-only sharingModel plus an owner_id field; the master object for a controlled_by_parent detail; a real remote owner_id for a federated phantom-anchor object; or "cannot be turned on" for a bypass-listed platform object. A public object that has an owner column stays admitted, because the grant service there still grants an external member of the record's organization (sharing: a share or sharing rule to an external member is refused as inert on a public_read_write or model-less system object, though the external OWD floor would consult it #25549).
  2. The /data door (Setup's New and edit forms, scripts, imports): bindRuleObjectEnforcementGuard binds beforeInsert / beforeUpdate on sys_sharing_rule, bound in sharing-plugin.ts beside the criteria guard. An insert is checked; an update only when it moves the rule onto another object (object_name supplied and different from ctx.previous), so an administrator can still switch off a rule saved before this change. System writes pass, as with the declared-name guard.
  3. POST /api/v1/sharing/rules (SharingRuleService.defineRule): the same check for non-system callers, before the insert or upsert; an upsert that keeps a stored rule on its object is not a move.

The refusal is SharingRuleObjectNotEnforcedError (rule-criteria.ts) with code: 'SHARING_NOT_ENABLED' and status: 422: an existing code in the @objectstack/plugin-sharing ledger row, and the pair the per-record share routes and POST .../evaluate already answer for the same verdict. No new code, no packages/spec change. Both doors answer a declared code + status at its own status, the same mechanism DeclaredSharingRuleNameConflictError (409) uses on these doors.

Docs: content/docs/permissions/sharing-rules.mdx and content/docs/kernel/runtime-services/sharing-service.mdx say the rule is refused at save. One changeset, @objectstack/plugin-sharing: patch, with the BREAKING banner and one ADR-0087 marker.

Tests

packages/plugins/plugin-sharing/src/sharing-rule-object-enforcement.test.ts, real ObjectQL + SqlDriver (better-sqlite3 :memory:), so the hook runs on the engine's own write path:

  • /data door: an insert on an owner-less object (the sys_session shape) is refused with SHARING_NOT_ENABLED / 422, the sentence names the object, sharingModel and owner_id, and nothing is saved; CONTROL: an insert on an enforced object saves; CONTROL: a public object with an owner column is admitted; an update that moves a rule onto a non-enforced object is refused and the row keeps its object; an update that leaves a legacy rule on its object passes.
  • defineRule: a rule on an owner-less object is refused (code + status) and nothing is saved; CONTROL: a rule on an enforced object saves; an upsert that moves an existing rule onto a non-enforced object is refused.
  • Federated remedy (contract review should-fix, dc804fb): a public_read_write federated object (external: { remoteName }) whose owner_id is the registry's injected anchor is refused at the /data door with 422 SHARING_NOT_ENABLED, nothing is saved, and the sentence says to declare a real remote owner_id column, not the sharingModel: 'private' remedy. Before the fix: 1 failed | 8 passed (9); after: 9 passed (9).
Readings (final head dc804fb: plugin-sharing `Test Files 68 passed`, `Tests 1445 passed`, typecheck exit 0, derived gates 96/96 exit 0 with `--ran` 0 NOT-MEASURED; full dogfood not re-run, as the delta is one remedy sentence, its pin and docs. Earlier readings below are on head 6a24dc2; the code is unchanged since 1390d36, the last commit is the census row on a docs page)
  • New pins, before the fix: Tests 8 failed (8) (bindRuleObjectEnforcementGuard is not a function). After: Tests 8 passed (8), lock VERDICT command-exit 0.
  • Ablation on the committed fix, through scripts/ablation-replace.mjs (if (!reason) return null; → if (reason || !reason) return null; in sharingRuleObjectRefusal): Tests 4 failed | 4 passed (8). The 4 red are exactly the 4 refusal pins; the 4 controls stay green. Restore proven: blob after restore == HEAD blob 2afbcaad0cda, git diff HEAD empty. The suite imports src/ by relative path, so no dist rebuild is on the path.
  • @objectstack/plugin-sharing pnpm test: Test Files 68 passed (68), Tests 1444 passed (1444); pnpm typecheck (incl. check:test-typecheck) exit 0. One lock run, VERDICT command-exit 0.
  • Full dogfood (pnpm --filter @objectstack/dogfood test, no shard, after a whole-tree turbo run build --concurrency=2, 119/119 tasks): Test Files 425 passed | 3 skipped (428), Tests 3543 passed | 41 skipped (3584), VERDICT command-exit 0. Measured on 1390d36; 6a24dc2 adds only the census row in content/docs/permissions/system-context.mdx.

Acceptance notes

  • Measured, not assumed: which objects are refused. Registering every object @objectstack/platform-objects exports into an ObjectQL registry and asking sharingRuleObjectRefusal: 58 of 58 are refused, among them sys_session, sys_account and sys_api_key (no owner_id, no declared model), the three the card names. A custom object with an owner_id and a private model is admitted (the CONTROL pins).
  • Error code and status: existing. SHARING_NOT_ENABLED is already in the @objectstack/plugin-sharing row of ERROR_CODE_LEDGER; 422 is its published status on the share routes and POST .../evaluate. A scratch probe (not committed) of @objectstack/rest's mapDataError and classifiedRefusalAnswer with this error answered 422 / SHARING_NOT_ENABLED with the sentence as error on both the /data door and the sharing-rule routes.
  • Stored rules on non-enforced objects in the fixtures: 0. The upgrade-harness fixtures hold one sys_sharing_rule row (fixture-18.0, fixture-18.0.0: upgrade_harness_red_projects on showcase_project, enforced; fixture-18.0.0-next.5: none). Declared sharingRules are not stored rows and are already held by validate-sharing-rule-enforceability's anchor findings. Stored rules are not migrated; one already saved keeps sharing nothing and can still be switched off.
  • Same predicate as the reconcile. The save doors refuse exactly when assertNotInertGrant would throw: the private objectInertReason is now the one reading both call. A public object with an owner column is admitted on purpose (sharing: a share or sharing rule to an external member is refused as inert on a public_read_write or model-less system object, though the external OWD floor would consult it #25549, external recipients still receive grants). An object the engine has no schema for is admitted, as the grant pre-flight's inertness half admits it.
  • System writes pass, mirroring bindDeclaredRuleNameGuard: the platform's own writers (the ownership ceremony, migrations) and the existing system-caller pins (system-caller-inert-grant.test.ts, which defines such rules to pin the reconcile refusal) are unchanged. The system-context census row 40 now names bindRuleObjectEnforcementGuard and defineRule (register regenerated with --write).
  • Not in this PR: Setup: show what people can really do and what really changed, using names rather than ids #30209 row 5 (the object picker still lists every object); this refusal is that card's option C on the save side only.

Verification

Derived gates (head 6a24dc2)
  • node scripts/pm/dispatch-gates.mjs --commands: 96 families over 10 changed paths. All 96 ran on 6a24dc2, each :: exit 0.
  • node scripts/pm/dispatch-gates.mjs --ran FILE: ✓ dispatch-gates --ran: 96 derived famil(ies) accounted for — 96 run, 0 NOT-MEASURED.
  • First pass on 1390d36: 95 green, pnpm check:system-context-census red ([read-without-a-row] for bindRuleObjectEnforcementGuard and defineRule); fixed in 6a24dc2 by keying row 40, then --write; re-run green.
  • Families CI runs beyond the derivation (lint farm, Test Core, type-check lanes) are CI's; this PR cites them from its check-runs.

🤖 Generated with Claude Code

https://claude.ai/code/session_016WV4cyFB75QwwCDu3S5GNZ

…rd sharing

A sys_sharing_rule on an object no sharing gate consults saved 201 and then
shared nothing. Both save doors (the sys_sharing_rule hook guard and
defineRule) now refuse it with SHARING_NOT_ENABLED 422, reading the
reconcile's own D7 inert-grant verdict.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyFB75QwwCDu3S5GNZ
…out record sharing, and its changeset

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyFB75QwwCDu3S5GNZ
… object-enforcement guard

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4cyFB75QwwCDu3S5GNZ
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

38 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 256a4b725116859f891160ad71b21c2e092d1fb0.

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

What this run could not see
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 222 client-bound route-ledger rows — the other 159 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 159: 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; 104 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 256a4b725116859f891160ad71b21c2e092d1fb0 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 256a4b725116859f891160ad71b21c2e092d1fb0

⚠️ 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 256a4b725116859f891160ad71b21c2e092d1fb0 → 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:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Contract review for card #30621 · narrowing face (Clause-②: no (narrowing)) · read-only reviewer at the contract-review tier, brief per record-recognisers --brief-template --face narrowing · 2026-11-06T14:37Z

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6a24dc2567408d3726317e55a2454d1f31c030d2
Local-runs: none
Scope: packages/plugins/plugin-sharing/src/rule-criteria.ts, packages/plugins/plugin-sharing/src/rule-hooks.ts, packages/plugins/plugin-sharing/src/sharing-plugin.ts, packages/plugins/plugin-sharing/src/sharing-rule-object-enforcement.test.ts, packages/plugins/plugin-sharing/src/sharing-rule-service.ts, packages/plugins/plugin-sharing/src/sharing-service.ts + packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts, packages/plugins/plugin-sharing/src/declared-sharing-rules.ts, packages/plugins/plugin-sharing/src/index.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-hook-dispatch.ts, packages/objectql/src/engine-execution-plumbing.ts, packages/objectql/src/registry.ts, packages/rest/src/sharing-routes.ts, packages/rest/src/error-response.ts, packages/rest/src/batch-routes.ts, packages/lint/src/validate-sharing-rule-enforceability.ts, packages/spec/src/api/error-code-ledger.zod.ts, docs/adr/0111-record-share-management-authority-and-verb-boundary.md, content/docs/permissions/sharing-rules.mdx (read as the published spec), hotcrm src/sales/sharing/demo-staffing.ts and its crm_* objects (read as the consumer)

Inputs: card #30621 (body, triage 6685173622, claim 6685311068 and its re-post 6685434298, os-dev-report 6686849997), PR #30953 (body, file list, net diff against main at the head), the head's check-runs. The review tree is main at 4d56914 (2026-11-06), on which the head's six code files differ from main by exactly the PR's hunks.

Check-runs on the head (read 2026-11-06, every run concluded): 38 success, 12 skipped (the UI lanes, the opt-in harnesses and the schedule gate, none of which the diff reaches), 0 failure. The ten required contexts that this PR's paths engage all concluded success: Lint & Repo Gates, TypeScript Type Check, Test Core (8 of 8 shards), Build Core, Governed Surface Queue Guard, Live E2E UI, Test UI; Lint UI, Type Check UI and Build & E2E UI were skipped by the path filter. Also green: Check Changeset, Lint · docs & code guards, Lint · PM dispatch-gates, Spec property liveness, Type Check · workspace, Dogfood Regression Gate. These conclusions are the gate verdicts this record adopts.

① Derived judgments

Accept-set changes the diff implies, each judged against the cited code:

  1. /data insert on sys_sharing_rule (non-system caller) — refused with SHARING_NOT_ENABLED / 422 when SharingService.objectInertReason(object_name) is non-null; saved otherwise. Right. The engine dispatches beforeInsert once per row for an array insert too (engine.ts rowHookContexts, dispatch.mode: 'per-row'), so a batch insert is checked row by row; the guard's Array.isArray(data) early return is unreachable on the engine's own path (observation, harmless).
  2. /data update on sys_sharing_rule (non-system caller) — checked only when object_name is supplied and differs from ctx.previous.object_name; a patch that keeps the object (switch-off, relabel, criteria edit) passes. Right, and fail-closed on the shapes the brief asked about: the by-id path binds previous from the pre-image, the predicate path dispatches per matched row with previous bound (dispatchPerRowBeforeHooks), and a dispatch that carries no previous falls through to the check rather than past it. A move plus a rename in one patch is judged on the object alone, which is the only field this guard owns; the declared-name guard beside it judges the rename.
  3. SharingRuleService.defineRule (non-system caller) — refused when no row of that name is stored in the caller's organization, or the stored row sits on a different object; an upsert that keeps the stored object is not a move. Right. The existing read is { name, organization_id: orgId } under the system context for every organization-scoped caller, so "stored on the same object" cannot be spoofed with another organization's row. The organization-less platform operator keeps the pre-existing { name } lookup; that is main's upsert behaviour, not widened or narrowed here.
  4. assertNotInertGrant refactor — now objectInertReason plus the throw; the verdict and the message are byte-identical to main's. Right. The grant pre-flight (grant(), both the system and the non-system arm) and assertSharingEnforced are unchanged in effect.
  5. The refusal envelope — SharingRuleObjectNotEnforcedError carries code: 'SHARING_NOT_ENABLED', status: 422, the same declared pair DeclaredSharingRuleNameConflictError (409) carries on the same two doors; classifiedRefusalAnswer reads declaredHttpStatus plus code, and the sharing-rule route's handleError relays it. SHARING_NOT_ENABLED is in the @objectstack/plugin-sharing row of ERROR_CODE_LEDGER (error-code-ledger.zod.ts:667); no packages/spec change, none needed. Right.
  6. Public surface — index.ts is untouched, so SharingRuleObjectNotEnforcedError, bindRuleObjectEnforcementGuard and RULE_OBJECT_ENFORCEMENT_GUARD_PACKAGE are not package exports; SharingService is exported, so sharingRuleObjectRefusal is a new public method on an exported class (additive). ISharingService / ISharingRuleService in the spec are unchanged. Right for a patch with a BREAKING banner (the break is the accept set, not the surface).
  7. System writes pass — buildSession propagates isSystem onto ctx.session, so the platform's own writers (upgrade-harness fixtures, migrations, the ownership ceremony, defineRule's own system write) are untouched, mirroring bindDeclaredRuleNameGuard. Right.
  8. Binding and rebinding — bound inside kernel:ready in the enforce !== false branch after this.service is constructed unconditionally, so the if (this.service) cannot skip it; it carries its own packageId, so unbindAllRuleHooks on every rule rebind leaves it standing. An enforce: false host binds no guard, exactly as it binds no criteria guard and evaluates no rule. Right.
  9. The consumer — hotcrm installs nine stored rules (demo-staffing.ts) on crm_opportunity, crm_lead, crm_task, crm_event, crm_case, each declared sharingModel: 'private' with an owner_id column: all admitted. Unaffected.

② Semver level

.changeset/30621-sharing-rule-object-not-enforced.md: '@objectstack/plugin-sharing': patch, the BREAKING banner, Clause-②: no (narrowing), Metadata-protocol: none, one ADR-0087 marker not-required (no-migration-prescription) (a listed category). The PR body carries the same two declaration lines and the title is fix(plugin-sharing)!:. Matches what the diff publishes: the only released package the diff moves is @objectstack/plugin-sharing; a save-door narrowing with no key added, removed, renamed or re-shaped is a non-protocol narrowing, which ships patch with its banner and disposition; declared sharingRules (the authorable metadata) are read from the registry (ADR-0131 D2) and never meet either door, so Metadata-protocol: none is right. Stored rows are neither migrated nor deleted, so there is no migration to prescribe. Check Changeset and Lint · docs & code guards concluded success on the head.

Clause-②: no (narrowing) — adopted.

③ Boundary flags

④ Design judgment

The narrowing against the published spec on main. ADR-0111 D7 ("grants that would enforce nothing are refused at write time rather than persisted as inert rows"), its 2026-10-24 amendment for #25549 (a public object whose owner_id is real admits an external recipient; every other recipient keeps the 422; controlled_by_parent stays refused), ADR-0078 (no silently inert metadata) and ADR-0049 all declare the refusal; validate-sharing-rule-enforceability already makes it real for a DECLARED rule's anchor at authoring (anchorFindings). The stored-rule save doors were the one authoring surface where the refusal was not real: the row saved and the reconcile refused every grant. The PR makes the spec's own refusal real at the rule's door. It takes no declared capability away: the only rules it refuses are those whose every grant the reconcile refuses on main today, and the recipient-decided arm (public model, real owner column) stays admitted exactly as the amendment and the sharing-rules page's external-recipients paragraph declare it. Not an implementation gap narrowed at the consumer; the spec's refusal, delivered.

Probes:

  • Can a non-system caller reach a non-enforced object another way? Batch insert: per-row beforeInsert dispatch, checked. Predicate update: per-row beforeUpdate with previous bound, checked when it moves the object. A missing pre-image: falls to the check, refused. Rename plus move in one patch: the object compare does not depend on the name. The generic batch route (batch-routes.ts) writes through ql.insert / ql.update, so the hooks fire; the engine has no upsert verb and the REST layer exposes none. The one remaining door is a system-context write, left open by design (the platform's own writers) and harmless: the reconcile still refuses every grant such a row asks for.
  • Is defineRule's existing lookup by name within the right organization? Yes for every organization-scoped caller ({ name, organization_id: orgId }, read under the system context). The organization-less platform operator's { name } lookup is main's behaviour and is not touched (observation, not a finding of this PR).
  • Does the save verdict equal what the reconcile refuses? The reconcile materialises through this.sharing.grant(...) under the system context (sharing-rule-service.ts:2399), whose pre-flight is assertNotInertGrant over objectInertReason and then assertRecipientNotInert per recipient. The save door refuses iff objectInertReason is non-null, i.e. iff the reconcile refuses every grant on the object whoever the recipient is. The recipient-decided arm (sharing: a share or sharing rule to an external member is refused as inert on a public_read_write or model-less system object, though the external OWD floor would consult it #25549) is admitted at save and refused per internal recipient at reconcile, which is the declared behaviour. Federated objects: a non-public object whose owner_id is the injected phantom anchor is refused on both (the hasPhantomOwnerAnchor arm); a public one with the anchor is refused on both (the generic arm, since the external lift requires a real column); one whose author declared a real remote owner_id is admitted on both. An object the engine has no schema for is admitted on both. Equal.
  • Can boot-time seeding or package install now fail or abort boot? No. Declared sharingRules are read from the registry on every read of the rule set (ADR-0131 D2, declared-sharing-rules.ts) and are never written to sys_sharing_rule; defineRule's only in-tree caller is the REST route with the request's own context; upgrade-harness fixtures and the stored-row migrations write under the system context, which the guard lets pass; the guard throws only inside a non-system write, and nothing at boot is one. The hotcrm installer's rules all sit on private, owned objects.

Findings:

  • should-fix — sharingRuleObjectRemedy names the wrong remedy for a PUBLIC federated object whose owner_id is the injected phantom anchor. objectInertReason refuses it through the generic "public sharing model or no owner_id" arm (the external lift requires a real column), and the remedy then falls to the last sentence, "give the object a private or read-only sharing model and an owner_id field". The object already has an owner_id, and following the sentence (declare private) lands the author on the phantom-anchor refusal. Test hasPhantomOwnerAnchor(schema) before the model, so a federated object is told to declare a real remote owner_id whatever its model. Message only; the verdict is right, and the four refusal pins hold either way.
  • observation (prose, outside this review's floor) — the paragraph the PR adds to content/docs/permissions/sharing-rules.mdx sits directly under main's sentence "On a public_read_write object, or a system object with no sharingModel, a share or sharing rule takes effect for external recipients only"; for an owner-less system object the two now read against each other, and the ADR's wording ("whose owner_id is real") is the precise one. For the lane's next docs touch.
  • observation — the Array.isArray(data) early return in bindRuleObjectEnforcementGuard is dead on the engine's per-row dispatch; harmless, and shared with the criteria guard.

No must-fix.

Implemented-by: claude/project-thread-lpcxlh
Reviewed-by: session_016WV4czvZykUqhYxYXSSDt4

VERDICT: PASS

…l, and reconcile the sharing-rules page with D7's real owner_id

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

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Delta contract review for card #30621 · narrowing face · 6a24dc2..dc804fb · read-only reviewer at the contract-review tier · 2026-11-06T15:10Z

Delta contract review for card #30621 · PR #30953 · narrowing face (Clause-②: no (narrowing)) · read-only reviewer at the contract-review tier · delta 6a24dc2..dc804fb on top of the PASS record for 6a24dc2 (comment 6687390627) · 2026-11-06

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: dc804fbb17e230449f01be794255263eea9ffd06
Local-runs: none
Scope: packages/plugins/plugin-sharing/src/sharing-rule-object-enforcement.test.ts, packages/plugins/plugin-sharing/src/sharing-service.ts + content/docs/permissions/sharing-rules.mdx (read as the published spec), packages/plugins/plugin-sharing/src/federated-phantom-anchors.ts, packages/plugins/plugin-sharing/src/rule-hooks.ts (unchanged, read for the door), docs/adr/0111-record-share-management-authority-and-verb-boundary.md (D7 and its 2026-10-24 amendment)

This is a delta review: it re-judges the one commit pushed since the prior PASS record (dc804fb, "name the federated-anchor remedy before the model, and reconcile the sharing-rules page with D7's real owner_id"), read through gh api compare and the contents API at the head; the prior record's judgments ①1–9 stand for everything the delta does not touch. The compare is ahead 1, behind 0 and touches exactly three files: sharing-service.ts (+4/−1, one hunk), sharing-rule-object-enforcement.test.ts (+26/−1), sharing-rules.mdx (+9/−8).

Check-runs on the head (read 2026-11-06, every run concluded after the Test Core aggregate finished): 50 runs, 38 success, 12 skipped (the UI lanes, the opt-in harnesses and the schedule gate, none of which the delta reaches), 0 failure. The required contexts this PR's paths engage all concluded success: Lint & Repo Gates, TypeScript Type Check, Test Core (8 of 8 shards and the aggregate), Build Core, Governed Surface Queue Guard, Live E2E UI, Test UI; Lint UI, Type Check UI and Build & E2E UI were skipped by the path filter. Also green: Check Changeset, Lint · docs & code guards, Check Documentation Links, Build Docs, Lint · PM dispatch-gates, Spec property liveness, Type Check · workspace, Dogfood Regression Gate. These conclusions are the gate verdicts this record adopts.

① Derived judgments

  1. The should-fix is closed in the tree. sharingRuleObjectRemedy now tests the phantom-anchor arm before the model: the condition schema && effectiveSharingModel(schema) !== 'public' && hasOwnerField(schema) && hasPhantomOwnerAnchor(schema) became schema && hasOwnerField(schema) && hasPhantomOwnerAnchor(schema). The remedy order is now bypass → controlled_by_parent → injected-anchor federated object (any model) → generic. A PUBLIC federated object whose owner_id is the registry's injected anchor is therefore told "declare a real remote owner_id column on the federated object" instead of "give the object a private or read-only sharing model and an owner_id field" — the sentence that led the author onto the phantom-anchor refusal. Right. The retained hasOwnerField guard is redundant with hasPhantomOwnerAnchor (the anchor is the injected field, so the provenance verdict implies presence) and harmless: where no owner_id is registered at all, the generic remedy is the correct one.
  2. The delta is message-only; no accept set moves. The service hunk is the single remedy condition. objectInertReason, inertGrantReason, recipientDecidesInertness, assertNotInertGrant, the sys_sharing_rule hook guard (rule-hooks.ts) and SharingRuleService.defineRule are untouched by the delta, so sharingRuleObjectRefusal returns null exactly when it did at 6a24dc2 and the same objects are refused on both doors. Walked per arm: a public federated object with the injected anchor was refused before and after through inertGrantReason's generic arm (the #25549 lift requires !hasPhantomOwnerAnchor(schema), so it never reaches null); its reason half is byte-identical and only the remedy half changes. A non-public phantom-anchor object: same sentence as before. A public object with a real owner_id: null, unchanged. An owner-less object: generic remedy, unchanged. A controlled_by_parent detail and a bypass object: earlier returns, unchanged. Right.
  3. The new pin. describe('[#30621] the refusal sentence names the remedy for the arm that refused') registers os30621_ext_customer as sharingModel: 'public_read_write' with external: { remoteName: 'customers' } and no declared owner_id, first asserts the registry injected the anchor (getSchema(...).fields.owner_id truthy, so the test cannot pass vacuously on a schema that never reached the phantom arm), then asserts the refusal names the object, carries the remote-column remedy and not the generic sharingModel: 'private' one, and that the /data insert is refused SHARING_NOT_ENABLED / 422 with nothing stored. It pins both halves: the corrected remedy and the unchanged verdict. The object is registered and deliberately left out of driver.initObjects — correct, a federated object has no platform-provisioned storage and nothing in the pin writes to it. boot() additionally returns sharing (additive; the earlier call sites destructure by name). The pin went into the component's existing harness file as a top-level describe, as the shared-harness rule requires. Right.
  4. The prose now agrees with ADR-0111 D7. The external-recipients paragraph gained the amendment's own qualifier, "whose owner_id is real", matching D7's "On an object whose effective model is public and whose owner_id is real (not a federated phantom anchor)". The refusal paragraph now enumerates exactly the arms D7.2 and its amendment refuse for every recipient: an object with no real owner_id (a system object without one such as sys_session, or a federated object whose owner_id is the platform's injected anchor), a controlled_by_parent detail, a bypass object — and no longer says "a public object with no owner", the phrase that read against the paragraph above it for an owner-less system object. A non-system private object with no owner_id, also refused by D7.2, falls under "an object with no real owner_id"; the parenthetical gives examples, not the full set, which is fine. Lint · docs & code guards, Check Documentation Links and Build Docs concluded success on the head. Agrees.
  5. Nothing new broken. index.ts and the spec are untouched, so the public surface judged in the prior record (one additive method on the exported SharingService) is unchanged; no new export, no envelope change, no registry change. The consumer (hotcrm's nine stored rules on private, owned crm_* objects) is unaffected by a remedy sentence. Unaffected.

② Semver level

Unchanged from the prior record: .changeset/30621-sharing-rule-object-not-enforced.md ('@objectstack/plugin-sharing': patch, BREAKING banner, Clause-②: no (narrowing), Metadata-protocol: none, ADR-0087 not-required (no-migration-prescription)) is not touched by the delta and needs no edit — a refusal-sentence wording, a test and a docs reconciliation publish nothing new. Check Changeset concluded success on the head.

Clause-②: no (narrowing) — adopted.

③ Boundary flags

④ Design judgment

The narrowing against the published spec on main, re-read for the delta. The prior record's judgment holds: the PR makes D7's own refusal real at the sharing rule's save doors and takes no declared capability away. The delta sharpens the one place the prescription disagreed with the arm that refused — a federated object already has the field the generic sentence told its author to add, and the model it told them to declare lands on the phantom-anchor refusal — so the sentence an administrator is shown now names the only remedy that would admit the rule (a real remote owner_id column, or an external.columnMap entry for one, per federated-phantom-anchors.ts). The page's two paragraphs now read as D7's two halves: the recipient-decided arm on an effective-public object with a real owner_id, and the for-every-recipient refusals.

Probes:

  • Does the reordered remedy ever change which objects are refused? No. sharingRuleObjectRemedy is reached only after objectInertReason returned a non-null reason, and that function is not in the delta.
  • Could the phantom arm now fire on a local object? No. hasPhantomOwnerAnchor is isOwnerAnchorUnprovable from @objectstack/spec/data, provenance injected-unprovisioned plus the columnMap arm; a local object's injected owner_id is provisioned, and an inexact match answers false.
  • Does the test's federated registration survive the real engine? The pin's first assertion checks the anchor was injected, and the suite ran green in Test Core on the head.

Findings:

  • observation — the hasOwnerField(schema) && guard in the phantom arm is now redundant with hasPhantomOwnerAnchor(schema); harmless, and leaving it documents the arm's precondition.
  • observation (prose) — "On a public_read_write object, or a system object with no sharingModel, whose owner_id is real" attaches the relative clause to both alternatives, which is the intended reading (both are effective-public); a future touch could say "and whose" for clarity. Not a divergence from D7.

No should-fix, no must-fix. The prior record's one should-fix is closed.

Implemented-by: claude/project-thread-lpcxlh
Reviewed-by: session_016WV4czvZykUqhYxYXSSDt4

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

Maintainer's per-PR word (BREAKING) · 2026-11-06T15:10Z

Elon chose 「同意落地」 on the seat's decision card in the server seat 4 thread, 2026-11-06T14:58Z, for this PR (card #30621: a sharing rule on an object without record sharing is refused at save with 422 SHARING_NOT_ENABLED). The word was given on the change at 6a24dc25; the one later commit dc804fbb changes only the remedy sentence for a federated object, its pin and docs, and its delta review is PASS. The seat lands dc804fbb.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#4 · os-elon-musk · session_016WV4czvZykUqhYxYXSSDt4

维护者速读

  1. 改了什么:在没有记录共享的对象上(如 sys_session)保存共享规则,现在两个保存入口(Setup 走的 /data 门、POST /api/v1/sharing/rules)都拒绝,答 422 SHARING_NOT_ENABLED,提示点名对象并说明怎样开启记录共享;把规则改到这类对象上同样拒绝。
  2. 为什么改:A sharing rule on an object without record sharing saves and silently does nothing #30621。以前这类规则保存成功、显示启用,但永远不共享任何记录,管理员看不出来。
  3. 风险:低到中。破坏性收窄:以前能存的这类规则现在存不进。判定和调和用的是同一个函数,不会不一致;已存的旧规则不迁移、不删除,仍可关闭。
  4. 先看:sharing-service.ts 的 sharingRuleObjectRefusal,rule-hooks.ts 的 bindRuleObjectEnforcementGuard。
  5. 两分钟验证:pnpm --filter @objectstack/plugin-sharing exec vitest run src/sharing-rule-object-enforcement.test.ts,预期 9 个全绿。
  6. 需要你做的:无。你已在决策卡上点头,席位现在落地。不属 Tier H,约 420 行 ≤ 3000。

席位意见:ACCEPT(#30621)。

席位核对

Head-sha: dc804fbb17e230449f01be794255263eea9ffd06 · read 2026-11-06T15:11Z

Item Reading
Shape Draft → main, Fixes #30621, BREAKING banner. 10 files. Branch claude/project-thread-lpcxlh, whole claim 6685434298. The census row is in content/docs/permissions/system-context.mdx, inside the claim's docs surface.
Seat diff read The /data door: a sys_sharing_rule hook guard that checks inserts and object-moving updates, and passes system writes. defineRule makes the same check for non-system callers unless an upsert keeps the stored object. Both ask SharingService.sharingRuleObjectRefusal, which reads the private objectInertReason that assertNotInertGrant now also reads. The error is the existing SHARING_NOT_ENABLED 422.
Evidence os-dev-report 6686849997: pins 8/8 with ablation (4 refusal pins red), plugin-sharing 1444, gates 96/96, full dogfood 3543 passed. Contract review PASS 6687390627 on 6a24dc25 (one should-fix: the federated remedy). Fixed in dc804fbb (pins 9/9, 1445 tests, gates 96/96); the delta review is PASS.
Gates (check-runs on this head) 38 success, 12 skipped, 0 failed.
Accepted deviations None.
Out of scope #30209 row 5 (the rule form's object picker still lists every object).
Semver @objectstack/plugin-sharing: patch. Clause-②: no (narrowing), Metadata-protocol: none, BREAKING banner, ADR-0087 not-required (no-migration-prescription).
Landing Not governed (check-governed-merges --pr 30953: 0 of 10 paths). BREAKING, with the maintainer's word on the decision card. The seat lands this head: pr_ready + automerge_enable.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 15:13
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 6, 2026 15:13
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Merged via the queue into main with commit ee874b1 Nov 6, 2026
55 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-lpcxlh branch November 6, 2026 16:42
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 Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A sharing rule on an object without record sharing saves and silently does nothing

2 participants