Repository navigation
assertControlledByParentWrite answers a metadata defect and a missing row with the same 403 PERMISSION_DENIED "requires edit access to its master record" #7474
Description
Found while classifying the [Security] Access denied family for #7451 (PR #7471). Filed rather than fixed: that card is user-facing copy for the gates it converted, and this one is not a copy bug — the sentence is wrong because the branch is wrong, so fixing it decides something about the envelope.
What was measured
packages/plugins/plugin-security/src/security-plugin.ts — assertControlledByParentWrite funnels six distinct conditions through one deny() helper, which composes one sentence and one code:
const deny = (reason: string, recordId?: unknown) => {
throw new PermissionDeniedError(
`[Security] Access denied: ${operation} on '${object}' requires edit access to its master record (${reason})`,
{ operation, object, recordId },
);
};The six reason values it is called with:
reason |
What actually happened | Is "requires edit access to its master record" true? |
|---|---|---|
no edit permission on master '…' |
caller lacks object-level update on the master |
yes |
master '…' not editable by this user (row-level security) |
master row outside the caller's write RLS | yes |
master '…' not editable by this user (record sharing) |
no OWD / record-share edit grant | yes |
controlled_by_parent declared but no master_detail relation |
the app's metadata is broken — the object declares the sharing model with no relation to hang it on | no |
target record not found |
the detail row does not exist | no |
detail record has no master reference |
the row's master FK is null | no |
The bottom three are not authorization verdicts. The caller is told, with a 403, that they lack access to a record — when the truth is that their app is misconfigured, or the row is gone.
Why it matters in both directions
- To the user, it is a false statement with a false remedy: "ask whoever owns the parent record" cannot fix a null FK or a missing
master_detailrelation. - To the app author, it is worse.
controlled_by_parent declared but no master_detail relationis a genuine authoring defect that ObjectStack can detect precisely, and it is currently disguised as a routine permission denial — the class of thing an author will never see, because 403s on a detail object look like ordinary RBAC noise. This is the "declared = enforced, and say so loudly" principle inverted. - The first condition is also unreachable-looking but not unreachable:
deny()returnsneveronly by throwing, andif (!rel) deny(...)is followed by non-null assertions (rel!.fk), so the metadata-defect branch is load-bearing rather than defensive.
Note also that 'target record not found' turns a concurrent delete into a 403 rather than a 404 — an answer that leaks less but says something untrue, and the two transports already disagree about what a PERMISSION_DENIED body carries (#7450).
What a fix has to decide
- Do the three non-verdict conditions keep
PERMISSION_DENIED, or get their own codes (a metadata/configuration error, and a not-found)? That is an ADR-0112 vocabulary question, not a copy edit — which is why this is filed rather than folded into fix(plugin-security,spec): the end-user-facing[Security] Access deniedgates stop showing developer copy (#7451) #7471. - Should the metadata-defect branch be a lint finding at publish time instead of (or as well as) a runtime refusal?
controlled_by_parentwith nomaster_detailrelation is statically detectable. - Once the branches are separated, the genuine-verdict half becomes convertible to the user-facing operation-message catalog exactly as fix(plugin-security,spec): the end-user-facing
[Security] Access deniedgates stop showing developer copy (#7451) #7471 converted the row-level gates — it is one of the end-user-facing gates that card identified but deliberately did not touch for this reason.
Pointers
packages/plugins/plugin-security/src/security-plugin.ts—assertControlledByParentWrite, thedeny()helper and its six call sitespackages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts:315— the current pin, which asserts only the shared sentence and so cannot tell the six apartexamples/app-crm/src/objects/opportunity-line-item.object.ts, showcase'sinvoice/expense-report— real business objects that reach this gate- fix(plugin-security,spec): the end-user-facing
[Security] Access deniedgates stop showing developer copy (#7451) #7471 — the classification that surfaced it; The other ~40[Security] Access deniedsentences are still English-only developer copy shown verbatim to end users (same class as #7414, one template fixed) #7451 — the parent sweep card; The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450 — the related envelope question
Activity
claude commentedon Aug 10, 2026
Triage:
needs-user-decision+domain:identity.PERMISSION_DENIEDor get their own codes is an ADR-0112 closed-vocabulary question, and the lint-vs-runtime question rides on it. Execution after the ruling is well-scoped, so this goes to the decision box with a route attached.packages/plugins/plugin-security/src/security-plugin.ts— the shareddeny()sentence is live at :4068 on currentorigin/main(c6a4eeb; body cites :3981 — anchor drift only), with the three non-verdict call sites verified at :4074 (controlled_by_parent declared but no master_detail relation), :4086 (target record not found), :4089 (detail record has no master reference). The pin atcontrolled-by-parent-sharing.test.ts(~:313) asserts only/requires edit access to its master/, confirming it cannot tell the six conditions apart. Fix lands in plugin-security ⇒domain:identity.assertControlledByParentWrite,PERMISSION_DENIED,master record) over open issues/PRs in all three repos — no duplicate. The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450 (envelopedetailsserialisation, cli lane) and The by-id write pre-image gate resolves the row under the caller's own read scope, so an app-authored widener is still dead onprivateeven once checkAuthoredRowWrite admits it #7401 (pre-image read-scope) are adjacent but distinct decision cards, already cross-referenced in the body.origin/main@c6a4eebas above; premise stands.target:v17: not applied — the wrong-message/wrong-code branches fire on misconfigured metadata, deleted rows, or null FKs; not one of the four blocking classes (no data loss, no security hole, no declared≠enforced public contract, no first-hour-experience apology).本评论来自分诊座位 Routine(#5474 试点),不构成认领。
Generated by Claude Code
claude commentedon Aug 11, 2026
Maintainer ruling — 2026-08-11. From the four-lens decision review (platform long-term coherence / measured business pull / AI-agent error-resistance / startup scope discipline); the maintainer accepted the recommendation set in full.
Ruling: split the six legs by true semantics. The three genuine authorization legs (no object-level update / write-RLS / no share grant) keep
403 PERMISSION_DENIEDwith the current sentence. The three non-verdict legs change:controlled_by_parent declared but no master_detail relation→ a loud configuration-defect error (it is a precisely detectable authoring defect, not an access verdict);target record not found→ 404;detail record has no master reference→ 422 validation-shaped. Envelope vocabulary per ADR-0112; coordinate with #7450 (ruled in the same session).The AI-error lens weighted highest here: a false 403 systematically steers debugging — human or agent — toward permission changes when the truth is broken metadata; that misdirection burns agent rounds daily. State:
needs-user-decision→pm:queue.Recorded by the triage seat Routine (#5474 pilot) on the maintainer's direct instruction — ruling record, not a claim.
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
Claim: PM loop round 2 (identity lane)
Session:
session_01JKdeE2CjKviipthNQmZ7UbBranch:
claude/issue-7474-split-deny-legsWorktree: cloud session, own container/clone (
mode:cloud)Domain:
domain:identityFile surface:
packages/plugins/plugin-security/src/security-plugin.ts(assertControlledByParentWrite+ itsdeny()call sites),packages/plugins/plugin-security/src/controlled-by-parent-sharing.test.ts, possible small touches to plugin-security error-shaping helpers, one.changeset/*.md. ⛔ Out of surface:packages/runtime/packages/rest(that is #7450, cli lane, ruled same session) andpackages/specerror vocabulary (spec seat's; if a new code is genuinely required the dev stops and reports).Container & model: M (ruling-implementation, envelope semantics split across six legs),
mode:cloud,model: opusSerial constraints cleared:
security-plugin.tswas last touched by #7471 (merged today,c6a4eeb) — base includes it, no in-flight claim on this file found in open PRs or lane boards. Same-day-churn clause goes into the dispatch prompt.Dispatching per the maintainer ruling of 2026-08-11 (split the six legs by true semantics; 403 kept only for the three genuine authorization legs).
Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
{ "issue": 7474, "status": "done", "branch": "claude/issue-7474-split-deny-legs", "pr": "https://github.com/objectstack-ai/objectstack/pull/7506", "premise_still_valid": true, "summary": "Premise re-verified on current origin/main (211abdb, includes #7471's c6a4eeb rewrite): the shared deny() helper and all six call sites are live, at :4066-4071 / :4074 / :4086 / :4089 (issue's line numbers were stale, structure unchanged), and controlled-by-parent-sharing.test.ts:315 still pins only /requires edit access to its master/. Implemented the ruling: the three genuine authorization legs keep 403 PERMISSION_DENIED with the sentence byte-for-byte; the three non-verdict legs get their own errors in plugin-security's errors.ts — controlled_by_parent-without-master_detail to 422 INVALID_METADATA, target-row-absent to 404 RECORD_NOT_FOUND, empty master reference to 422 MISSING_REQUIRED_FIELD. NO new error code and packages/spec untouched: all three are drawn from the existing ADR-0112 vocabulary, per the ledger's own rule that a generic condition takes an existing entry over a registered synonym. Two design points the reviewer should check: (a) none of the new messages opens with '[Security] Access denied', because that prefix is a MATCHER in isPermissionDeniedError / mapDataError / rest-server's sanitiser and would re-flatten the split back to 403 on the wire; (b) each error carries BOTH status and statusCode, since mapDataError passes through on .status alone while the dispatcher's errorFromThrown reads .status then .statusCode. Nothing is designed to depend on `details` reaching the client (#7450). packages/runtime and packages/rest are untouched. Throwing directly instead of via a never-returning helper also retired the rel! non-null assertions the issue flagged as the load-bearing half of the same defect.", "tests": "Gate-order verification (PM hypothesis 3), measured not assumed: readRowById probes the detail under { isSystem: true } and the middleware short-circuits on opCtx.context?.isSystem, so an RLS-hidden row is FOUND at the not-found leg and falls through to an authorization leg; the object-level CRUD gate (step 2, :1238) and the row-level write pre-image check (step 2.7, :1343) both precede this gate (step 2.8, :1516), so a caller reaching it already holds update on the detail. Both halves are pinned. 9 new cases in controlled-by-parent-sharing.test.ts, every one asserting the ADR-0112 code AND status (never a bare toThrow), with the three 403 legs additionally asserting the sentence verbatim; two of those legs (no object-level update on the master; master row outside write RLS) had NO coverage before. Reverse verification, direction predicted first: fix removed with `git checkout origin/main --` on the two source files (never git stash), predicted 5 red = 3 new-envelope cases + insert variant + distinguishability, with the 3x403 and the RLS-hidden case green. Measured exactly that: 'Tests 5 failed | 22 passed (27)', each failure receiving PERMISSION_DENIED where the new code was expected; restored -> 27 passed. Pin sweep: repo-wide grep of all six leg fragments found exactly ONE consumer pin outside the edited file (/requires edit access to its master/ at :315, an authorization leg, kept verbatim); positive control confirmed the greps (master_detail relation returned 10 hits elsewhere). Per #7471's lesson that grep is necessary and not sufficient, the consumer suites were RUN: @objectstack/plugin-security 931 passed (45 files, full suite), @objectstack/dogfood 546 passed | 3 skipped (includes showcase-invoice-cbp.dogfood.test.ts), @objectstack/example-crm 27 passed, @objectstack/example-showcase 168 passed, @objectstack/runtime 1939 passed, @objectstack/rest 1341 passed. Consumer direction: the sweep list came from `pnpm --filter '...@objectstack/plugin-security'` (PREFIX = downstream consumers) and the suites above are the subset that reaches this gate. Build closure first per toolchain trap 2 (`--filter '@objectstack/plugin-security^...' build`), then `pnpm --filter @objectstack/plugin-security typecheck` clean. Gates: check:nul-bytes OK (6994 files), check:error-code-casing OK (3575 files, ADR-0112), check:docs-audit-scope green. CI on the draft PR: in_progress at report time (not polled — PM owns convergence). A .changeset/ entry is included (user-visible envelope change), so no skip-changeset label applies. The docs-drift bot flagged 12 hand-written docs; checked them — none documents this gate's message or code, so no doc edit is owed.", "open_questions": [ { "question": "The metadata-defect leg's status was the one thing the ruling left to the implementer ('a loud configuration-defect error', no status named). Chose 422 INVALID_METADATA.", "options": [ "A: 422 INVALID_METADATA — the shape metadata-protocol already uses for 'your metadata is broken'; 4xx keeps the message on the wire, and the message IS the remedy", "B: 500 INTERNAL_ERROR — semantically 'not the caller's fault', but both transports drop a 5xx producer's prose unconditionally, so the author receives nothing actionable", "C: register a new plugin-security code (e.g. CONTROLLED_BY_PARENT_MISCONFIGURED) — needs a packages/spec vocabulary edit, which this card ruled out" ], "recommendation": "A. B defeats the ruling's own rationale (the AI-error lens: the false 403 misdirects debugging, and a prose-less 500 misdirects it just as much); C is the spec seat's surface and ADR-0112's ledger explicitly says a generic condition takes an existing catalog entry over a registered synonym. Note the two 422 legs share a status and are told apart by `code`, which ADR-0112 makes the branch point." }, { "question": "The 404 leg fires on a genuinely absent row — verified — but readRowById also swallows ENGINE exceptions into the same null, so a driver outage now answers 404 (it answered 403 before). Recorded rather than fixed, per the dispatch's instruction not to silently widen scope.", "options": [ "A: land as ruled and treat the probe's error-collapsing as a separate card (filed as #7505)", "B: fail closed on an engine throw inside this gate only — a local 403 for an outage, inconsistent with the 404 the ruling ordered for the leg" ], "recommendation": "A, done — the collapse is in a SHARED probe (getCallerPreImage reads it too), so fixing it in this PR would change several gates' outage behaviour under a card that ruled on none of it." } ], "out_of_scope_findings": [ "filed as #7503: publish-time lint for `sharingModel: controlled_by_parent` with no master_detail relation — the card's question 2, which the ruling did not order. Confirmed statically detectable: the rule family already lives in packages/lint/src/validate-security-posture.ts next to security-master-detail-ungranted, and the check mirrors resolveCbpRelation's three-step fallback.", "filed as #7504 (label `finding`): error-code ledger provenance — INVALID_METADATA now has a second emitter (@objectstack/plugin-security) and is registered under @objectstack/metadata-protocol only. No wire or gate consequence today (the union dedupes, so every conformance parse passes), but the ledger's retirement rule reads those rows as the emitter list, so a future retirement pass would unregister a code plugin-security still throws. One line, in the spec seat's file.", "filed as #7505: readRowById collapses 'absent' / 'engine threw' / 'no engine wired' into one null, so a store outage is indistinguishable from a missing row at every gate that probes with it — and after this PR that answers 404 RECORD_NOT_FOUND, which an SDK reads as terminal rather than as a transient fault." ] }Generated by Claude Code
os-zhuang commentedon Aug 11, 2026
ACCEPT — PR #7506 implements the 2026-08-11 split-by-true-semantics ruling. Review of record (identity-lane PM, session
session_01JKdeE2CjKviipthNQmZ7Ub):403 PERMISSION_DENIEDwith the sentence byte-for-byte (denyMasterEdit, asserted verbatim in tests); the three non-verdict legs throw dedicated errors —422 INVALID_METADATA,404 RECORD_NOT_FOUND,422 MISSING_REQUIRED_FIELD— all from the existing ADR-0112 vocabulary,packages/specuntouched,packages/runtime/packages/restuntouched (The runtime dispatcher serialises a PermissionDeniedError'sdetailsto the client, sopositions/permissionSetsreach the browser on the/datatransport #7450's surface respected).code+status), never a baretoThrow; the distinguishability case pins four envelopes side by side; the two 422 legs are told apart bycode. Two design points verified in the diff: the new messages deliberately avoid the[Security] Access deniedmatcher prefix (which would re-flatten the split at both transports), and each error carries bothstatusandstatusCodefor the two transports' different reads.git checkout origin/main --(never stash), 5 red predicted / 5 red measured, restored green. Consumer suites run beyond grep (runtime 1939, rest 1341, dogfood 546, both example apps) per fix(plugin-security,spec): the end-user-facing[Security] Access deniedgates stop showing developer copy (#7451) #7471's pinned-regex lesson.INVALID_METADATA— a 5xx would have both transports drop the prose that IS the remedy, defeating the ruling's own AI-error rationale; and it reuses metadata-protocol's existing "your metadata is broken" shape rather than minting a synonym. Veto window via the round report.deny()and what the split exposed): Publish-time lint:sharingModel: controlled_by_parentwith nomaster_detailrelation is statically detectable and unreported #7503 (publish-time lint forcontrolled_by_parentwithoutmaster_detail— the card's question 2, which the ruling deliberately left unordered), Error-code ledger provenance:INVALID_METADATAnow has a second emitter (@objectstack/plugin-security) and is registered under only one #7504 (finding: error-code ledger provenance row for the secondINVALID_METADATAemitter — one line in the spec seat's file),readRowByIdswallows engine failures intonull, so a store outage is indistinguishable from an absent row at every gate that probes with it #7505 (readRowByIdcollapses absent/engine-threw/no-engine into onenull; after this split an outage answers 404 — the honest residual, correctly filed instead of widened into this PR).Landing: TypeScript Type Check is the one job still running; once it concludes
successthis PR goes ready + auto-merge into the queue (nodocs/adr/**— normal path).Generated by Claude Code
fix(plugin-security)!: split controlled_by_parent write refusals by t…
feat(lint): report `controlled_by_parent` with no relation to derive …
fix(plugin-security): propagate engine faults from readRowById instea…
fix(spec): register INVALID_METADATA's second emitter in the error-co…
fix(plugin-security): compose controlled_by_parent across a chain (#1…
fix(plugin-security): compose controlled_by_parent across a chain (ob…