Repository navigation
A public_read_write object is only writable by the row's creator — showcase_contributor gets 403 editing a showcase_project it can read, where the access matrix declares edit:true #8023
Description
Activity
Triage: lands in
packages/plugins/plugin-security(the #5492owner_only_writesfloor stays composed into Layer 1 becauseISharingServiceabstains on apublic_read_writeobject, and per the documented composition an abstain keeps the floor — contradicting the OWD contract that declares writes open). Staysdomain:identity; queuedpm:queue.Rationale: only failing cell of a 124-cell matrix re-drive, with two independent mechanism confirmations (
/security/explainreportingnarrowswith zero authored RLS; the cross-object creator-vs-non-creator PATCH split). The filer honestly flags that attribution to the92f26f75 → b602d536window lacks a build ablation — the dev should reproduce first, per the card.Acceptance is well-specified on the card, including the non-regression edges: the object-level gate's distinct refusal, the
created_byfloor onprivate/public_readobjects, and #7792's by-id write-visibility behaviour.
Generated by Claude Code
Triage addendum:
target:v17added. Binary test: a published OWD (public_read_write) whose declared write grant does not hold — the access matrix saysedit:trueand the platform answers 403 — is a class-① released-surface defect users hit today (found by QA matrix re-drive, regression inside the92f26f75 → b602d536window).
Generated by Claude Code
Claim: PM loop round 1 (
domain:identityseat #6022)
Session:session_01PEVB6w7D7uCszR9Mw1BL73
Branch:claude/issue-8023-owd-public-read-write-floor
Worktree:objectstack-issue-8023
Domain:domain:identity
File surface:packages/plugins/plugin-security/src/**— expected landing pointsobjects/default-permission-sets.ts(theowner_only_writesseed),platform-ownership-policies.ts, and the Layer-1 composition insecurity-plugin.ts/explain-engine.ts, plus their tests.⚠️ packages/plugins/plugin-sharing/**is out of surface — see serial constraints; if the fix turns out to require changing the sharing side's abstain signal rather than how the security side composes it, stop and report.
Container & model: M,mode:subagent,model: opus— per the 2026-08-12 backend ruling (S-mechanical + M ⇒ subagent; cloud reserved for L/XL, work outliving the PM session, browser/dogfood verification, build-heavy M). The reproduction here is HTTP-level against a booted app and the regression surface is this package's own dogfood/matrix test files, so none of the cloud reservations apply.opusbecause the composition semantics are a judgement call on a security boundary, not a mechanical edit.
Serial constraints cleared:plugin-securityis free — no in-flight claim in this lane touches it. Both of this lane's in-flight PRs are in other packages and are now armed and in the queue: #7974 (plugin-sharing/src/share-link-service.ts) and #8017 (plugin-auth).⚠️ The three otherplugin-sharingqueue cards (#7807, #7858, #7795) stay held behind #7974 reaching MERGED, and #7994 behind #8017 — none of them is this card, and none overlaps this file surface.Premise re-verified on current
origin/mainbefore claimingThe card was filed 11:50Z today, so it is fresh, but the floor it names had to be confirmed rather than assumed:
owner_only_writes(object'*', operationupdate,using: created_by == current_user.id) still exists and is still seeded —objects/default-permission-sets.ts:415.- Last touch to either named file is
9a75790(feat(service-analytics): serve a$fieldRLS rule by declining native SQL (#7598) #7916, the$fieldRLS / analytics change). Nothing has landed since that would have already moved this behaviour.
⚠️ One correction to the card's own file attribution, carried into the dispatch: the card citesplatform-ownership-policies.tsas the policy's home. That file documents the floor (line 11), but the shipped seed is inobjects/default-permission-sets.ts:415. A dev that edits only the cited file would change a comment and ship nothing.Why this is dispatchable rather than escalated
The access matrix declares
edit: true,/meta/objectreportssharingModel: 'public_read_write', and no RLS is authored — three declarations agreeing, and the platform answering 403. That is a declared-≠-enforced defect on a published surface, i.e. restoring an invariant the platform already states, which is the named immediate-action class. It does not widen an accept set by design; it makes the runtime match the OWD contract.⚠️ It is nonetheless a write-authorization boundary, so the acceptance edges are non-negotiable and all four go into the dispatch verbatim — in particular that thecreated_byfloor must still refuse non-owner writes onprivate/public_readobjects. A fix that opens writes everywhere would satisfy the headline symptom and be a security regression.
Generated by Claude Code
{ "issue": 8023, "status": "done", "branch": "claude/issue-8023-owd-public-read-write-floor", "pr": "https://github.com/objectstack-ai/objectstack/pull/8072", "premise_still_valid": true, "summary": "Reproduced the card end-to-end on the real showcase before editing anything (PATCH 403 PERMISSION_DENIED with the record-level sentence; /security/explain reporting rls=narrows for update with zero authored RLS and not_applicable for read), then fixed it by conditioning the platform's wildcard owner_only_writes floor on the object's DECLARED OWD inside computeLayeredRlsFilter — an object declaring sharingModel:'public_read_write' no longer inherits the update floor at all. The PM's suggested route was the right one; the composition-semantics route was rejected because 'abstain' covers three distinct facts (public OWD / no owner field / bypass-listed internal) and only the first says writes are open, so widening it would re-open #5492's E2 measurement on the other two. plugin-sharing was NOT touched. Two things the PM should know: (1) the card omits a precondition — the floor is positions-gated to org_member, which a principal holds only via a sys_member row, so in an org-LESS harness the PATCH answers 200 and the defect is invisible; my first fixture was org-less and passed on the broken build, which is why the final fixture boots with orgContext:true and says so in its header. (2) The fix's placement is load-bearing: removing the floor at collection time (before #7665's derive-from-select branch) is what preserves #7792 — removing it at the existing dropPlatformOwnershipFloor filter instead would compile Layer 1 to null and hand back an ungated by-id write. One deliberate consequence, in the safe direction: on a public_read_write object where sharing answers 'allow' (ownership at depth / edit share / modifyAllRecords), the write is now also gated by the caller's derived select scope where it previously was not — a narrowing, consistent with #7665's 'you cannot mutate what you cannot see', and covered by case [D].", "tests": "Predict-then-mutate ablation (prediction written BEFORE the run): removing ONLY the decision-site hunk gave exactly the predicted split — 'Tests 2 failed | 7 passed (9)', failing set {[A public_read_write], [D #7792]}, with [D] failing on its FIRST assertion ('a readable, non-created row is writable: expected 403 to be less than 300') as predicted, and [B]/[C public_read]/[C private]/[E delete floor] green on BOTH sides (that invariance is the security claim). Restored + rebuilt: 'Tests 10 passed (10)'. NOTE a stale-dist lap: after restoring the source I re-ran without rebuilding and got a false red — the rebuild flipped it green. Regression sweep on current origin/main after rebase: plugin-security 'Test Files 50 passed (50) / Tests 1006 passed (1006)'; plugin-sharing 'Test Files 18 passed (18) / Tests 468 passed (468)'; full dogfood suite 'Test Files 96 passed | 1 skipped (97) / Tests 619 passed | 3 skipped (622)'; typecheck Done for plugin-security, plugin-sharing, dogfood. Gates re-derived for the final file surface via scripts/pm/dispatch-gates.mjs (check:authz-resolver no longer matches — objects/default-permission-sets.ts is NOT edited): check:cross-package-test-inputs OK, check:docs-audit-scope OK, check:test-source-alias OK, check:nul-bytes OK (7336 files, no control bytes) plus a manual grep -naP self-scan over the four changed files. No fake engine and no new error code, so check:engine-double-contract / check:error-code-casing do not apply. CI gate status at report time: in_progress.", "open_questions": [ { "question": "The card's second mechanism confirmation — a persona READS an admin-created sys_user_position row (200) but PATCHes it 403 — is NOT fixed by this PR, and I judged that correct rather than an omission. sys_* objects reach the same ISharingService abstain through the UNSET-model path (effectiveSharingModel maps an unset model on a system object to 'public'), not through a declared public_read_write. Should platform system tables open cross-creator writes too?", "options": [ "A. Leave as-is (this PR): only an explicit sharingModel:'public_read_write' declaration drops the floor. sys_* tables keep the created_by floor as their only row-level write gate.", "B. Extend the drop to the effective 'public' bucket, which would also cover unset-model sys_* objects and controlled_by_parent details." ], "recommendation": "A, and I implemented A. Real business need: nobody has asked for members to edit each other's sys_user_position / sys_* rows — the card cites it as EVIDENCE of the mechanism, not as a requirement, and the acceptance criteria do not mention it; B would be capability expansion with no measured consumer, which the startup-focus principle handles implementation-first. Long-term soundness: an unset model is a legacy default, not an author's declaration, and ADR-0090 D1 made unset fail closed precisely because the fall-through-to-public default caused the objectui#2348 incident — deriving a WRITE grant from a default the platform already decided was unsafe reintroduces that reasoning on the write axis. Hard-to-get-wrong: A keeps 'declared = enforced' exact (the runtime opens writes only where an author typed the words), while B would open writes on the platform's own identity tables as a side effect of a fix for app objects — the invisible-widening shape that is hardest to catch in review. If the maintainer does want cross-creator writes on some system table, the honest fix is to declare it on that object, not to widen the bucket." } ], "out_of_scope_findings": [] }
Generated by Claude Code
PM review: ACCEPT — PR #8072. Seat #6022, session
session_01PEVB6w7D7uCszR9Mw1BL73. CI convergence is still in flight and is mine, not yours; the verdict does not depend on it.Verified against GitHub, not against the report
check result Path face — docs/adr/**/.claude/skills/**/skills/**✅ zero hits — normal terminal path Declared out-of-surface: packages/plugins/plugin-sharing/**✅ untouched, confirmed by diff and not by your say-so Changed files vs declared surface ✅ exactly 5, all inside the claimed surface Changeset ✅ present First line Fixes #8023✅ correct — the full ruled scope is implemented The load-bearing placement claim ✅ the drop is inside computeLayeredRlsFilterat collection time, keyed on the declaredsharingModel, not oneffectiveSharingModelThe two judgements that make this a good fix rather than a passing one
The placement. You could have satisfied the headline symptom at
dropPlatformOwnershipFloorfurther down and every acceptance case would still have gone green — and it would have compiled Layer 1 tonulland handed back an ungated by-id write, reopening the hole #7665 closed. Fixing one security bug by reopening another is the single most expensive outcome available on this card, and the thing that caught it was[D]failing on its first assertion during ablation, exactly as predicted. That is the predict-then-mutate discipline earning its cost.Declared, not effective. Reading
effectiveSharingModelwould have foldedcontrolled_by_parentand unset-modelsys_*tables into the samepublicbucket and opened cross-creator writes on the platform's identity tables — as a side effect of a fix for app objects. That is the invisible-widening shape review is worst at catching. Keying on the author's declaration keeps declared = enforced exact.Your open question — A confirmed, and it was right to ask
A. Only an explicit
sharingModel: 'public_read_write'drops the floor;sys_*unset-model objects keep it. Your reasoning holds on all three axes and I'll add the decisive one: an unset model is a legacy default, not a statement, and ADR-0090 D1 already ruled that direction unsafe on the read axis after a real incident. Deriving a write grant from a default the platform has already decided cannot be trusted would re-litigate that ruling sideways, on the more dangerous axis, without anyone deciding to. The card cites thesys_user_positionsplit as evidence of the mechanism, not as a requirement — and its acceptance criteria never mention it. If cross-creator writes are ever wanted on a system table, the honest route is to declare it on that object.⛔ No follow-up card. This is a settled boundary, not a deferred one.
One consequence I want on the record, since it is a real behaviour change
You flagged it and you were right to: on a
public_read_writeobject where sharing answersallow(ownership-at-depth / edit share /modifyAllRecords), the write is now also gated by the caller's derived select scope, where previously it was not. That is a narrowing, it is consistent with #7665's "you cannot mutate what you cannot see", and[D]covers it. Accepted deliberately rather than noticed later — a narrowing on a write path is the safe direction, and it is now pinned.The fixture finding is the most valuable thing in this report
"my first fixture was org-less and passed on the broken build"
The floor is positions-gated to
org_member, which a principal holds only through asys_memberrow — so in an org-less harness the PATCH answers 200 and the defect is invisible. A fixture that cannot fail is worse than no fixture, and you caught yours by ablating rather than by reading it. That precondition is absent from the card, so anyone re-running the QA matrix org-less would have recorded this cell as passing.⚠️ That is a testing-harness gap larger than this card, so I am not letting it die in a PR body: filing it as afindingagainst the QA checklist path, where the grading is the triage seat's call and not mine.Next steps (mine)
CI was
in_progressat review time —Build Core✅,Dogfood Verify CLI✅, with Test Core, Dogfood Regression 1–3/3, TypeScript Type Check and ESLint still running. I will read the per-job conclusions (⛔ never the aggregate —get_statuson this repo returns only a Vercel status and readssuccesswhile saying nothing about any gate), then flip ready and arm. The stale-distlap you hit is worth remembering repo-wide: after restoring a source file, a re-run without a rebuild gives a false red.
Generated by Claude Code
Armed and queued — PR #8072. Seat #6022, closing the loop the ACCEPT left open.
Gate readings taken per job at 14:46Z, ⛔ never the aggregate:
job conclusion completed Test Core (1/3) ✅ success 14:32Z Test Core (2/3) ✅ success 14:34Z Test Core (3/3) ✅ success 14:29Z TypeScript Type Check ✅ success 14:36Z ESLint ✅ success 14:29Z Dogfood Regression Gate 1–3/3 + rollup ✅ success 14:29–14:30Z Temporal Conformance (live PG + MySQL) ✅ success 14:29Z Build Core · Dogfood Verify CLI · Check Changeset · the rest ✅ success / skipped — Zero failures. Before arming: head sha re-confirmed as
16f5682— the exact commit CI ran on, so no post-CI push slipped in — and the path face re-verified at that head (zero hits ondocs/adr/**/.claude/skills/**/skills/**), so this takes the normal terminal path rather than the human-merge fork.Then
draft: false→enable_pr_auto_merge. In that order, which matters: auto-merge does not survive a draft conversion, so arming first and flipping second would have silently disarmed it.Queue membership confirmed by the
added_to_merge_queuewebhook event — ⛔ not by theauto_mergefield and ⛔ not by agh-readonly-queue/*branch lookup, which false-positives on stale refs and false-negatives at capacity.Still open until MERGED, and this seat holds it until then: the landing check will re-verify content on
origin/main, including the negative space — thatunit_and_subordinatesand thedeletefloor are still where case[E]says they are. The queue squashes, so ancestry checks never fire and only content proves it.
Generated by Claude Code
MERGED and landing-verified — PR #8072 landed as
8c767f5f0. Seat #6022. This card is closed out.Verified by content on
origin/main, not by ref state — the queue squashes, somerge-base --is-ancestornever fires and only content proves anything.Positive:
claim on origin/mainthe floor drop is live security-plugin.ts:4046—if (meta.owdOpensRowWrites && owdOpenWritesCoversOperation(operation))keyed on the declared OWD, not the effective bucket :4635—owdOpensRowWrites: owdDeclaresOpenRowWrites(obj)Negative space — the deliberate non-changes, which are half the security claim:
must still be true reading [E]the delete floor survivesowner_only_deletesstill seeded,objects/default-permission-sets.ts:422unit_and_subordinatesuntouchedstill at sharing-rule-service.ts:730— and still routing through the same call, i.e. #7807's defect is intact and was not incidentally "fixed" by this merge⚠️ One reading that did not come back clean on the first pass, and needed resolving rather than assuming.effectiveSharingModelgreps 1 occurrence insecurity-plugin.ts, where the review claim was that it is never consulted. Chased it: line3141, inside a pre-existing doc comment about layers 2/3 andcontrolled_by_parent(#5815 / #4467), unrelated to this change and not a use. The claim holds — but "1, not 0" is exactly the shape that gets waved through, and a grep count is not a verification until someone reads the hit.Closed by the merge. The follow-on this unblocks — #7809, the bulk AST path passing
opCtx.operationraw — is being claimed now, with its file surface re-verified against this merged ref rather than against the state it was filed in.
Generated by Claude Code
- added 3 commits that reference this issue
on Aug 17, 2026
Found by a retest of
access-security.owd-sharing-matrixandaccess-security.crud-permission-matrix(QA run #7637) againstorigin/main@b602d536, rebuilt. This is the only failing cell of a 124-cell matrix re-drive.Symptom
A
showcase_contributorpersona can read a seededshowcase_projectbut cannot edit it:Message: "You do not have access to this record. Contact the person who owns it, or your administrator, if you need access." — the record-level pre-image sentence, not the object-level one. Reproduced twice; the project's fields are unchanged.
Expected 2xx, because all three inputs say so:
access-matrix.jsongrantsshowcase_contributoredit: trueonshowcase_project;GET /api/v1/meta/object/showcase_projectreportssharingModel: 'public_read_write';showcase_projectfor any position.The object-level gate still works correctly and is distinguishable: a
member_defaultpersona (edit:false) gets a different message — "You do not have permission to perform this action".Reproduction
POST /api/v1/auth/sign-up/email, then as adminPOST /api/v1/data/sys_user_permission_set {user_id, permission_set_id: <showcase_contributor>}andPOST /api/v1/data/sys_user_position {user_id, position:'contributor'}, then re-sign-in so the session carries the position.PATCHany seededshowcase_projectit did not create.Root cause (suspected, mechanism-evidenced)
The platform's own row-level write ownership floor stays composed into Layer 1:
member_default'sowner_only_writes— object'*', operationupdate,using: 'created_by == current_user.id', positions['org_member'](packages/plugins/plugin-security/src/platform-ownership-policies.ts, #5492) — remains in effect becauseISharingServiceabstains on apublic_read_writeobject ("sharing=not_applicable: Baseline already grants the rows sharing would add"), and per the documented #5492 composition an abstain KEEPS the floor.Net effect: on a
public_read_writeobject, record-level write is open only to the row's creator, which contradicts the OWD contract.Two independent confirmations that this is the
created_byfloor and not a derived select scope:POST /api/v1/security/explain {object:'showcase_project', operation:'update', userId:<persona>}reports the rls layer asnarrowseven thoughshowcase_projecthas zero authored RLS — whileoperation:'read'reportsnot_applicablewithreadFilter: null.showcase_field_ops_delegatereads an admin-createdsys_user_positionrow (200) but PATCHes it 403, while it can PATCH (200) and DELETE (200) asys_user_positionrow it created itself. Readable-but-not-creator ⇒ refused; creator ⇒ allowed.Why this is not PR #7792
On
showcase_projectthe contributor holds no select-class RLS at all, so the #7792 derivation (which derives a write scope from the caller's SELECT policies) can only compile a null filter there and cannot narrow anything. The retest also confirmed #7792's own guards positively — legitimate in-scope writes still succeed, the widener directions (#7401 / #6736) are not resurrected (6 dogfood files / 34 tests green, plus the fix's own 17/17 pin), andverify --rlsnow reports 20 PROVEN / 0 HOLES.Honest limit on the attribution: a build ablation (rebuilding
plugin-securityat the parent commit) was not run — the environment forbids mutating repo sources — so the root cause above rests on the mechanism evidence, not on a differential build. Pinning it to a specific merge needs that ablation.Acceptance criteria
edit:trueon apublic_read_writeobject can PATCH a row it did not create.edit:falsestill gets the object-level refusal, with its distinct message.created_byfloor still applies where it should — on objects whose OWD does not open writes (private,public_read), a non-owner write stays refused.Source
Retest of QA run #7637 (
owd-sharing-matrixclause 3, also surfacing as the single failing cell ofcrud-permission-matrixclause 0) atb602d536.