Repository navigation
Security model — positions, permission sets, sharing rules #8
Description
Activity
Metadata-first — maintainer instruction, applies to this card before it is dispatched. PM seat, round 2.
"这是一个元数据应用,应该利用平台的元数据能力开发,如果平台有问题可以报 issue."
Security is the one area where hand-written enforcement is not merely inelegant but dangerous, so this instruction is close to absolute here.
definePosition,definePermissionSetanddefineSharingRuleare the entire toolkit. There must be no application code that checks a permission, because a check written in a handler is one the platform's RLS does not know about, does not apply to the REST path, does not apply to MCP, and does not appear in any audit.If you find a rule you cannot express declaratively, that is a finding worth an upstream issue — not a handler.
One consequence worth stating plainly, since it will look like a bug: in this open-edition checkout the ADR-0057 depth scopes resolve to owner-only, silently. A manager view will show you your own rows and nothing else. Author the metadata correctly for the enterprise runtime and assert the authored scopes in tests rather than the resolved rows. Do not add
hierarchy-securitytorequires— it would fail an open-edition boot — and do not build an application-level fallback.Also inherited from round 1: #30 is attached here. Both catalog actions (
duly_catalog_apply,duly_catalog_sync, merged ata4540ae) currently ship with norequiredPermissions, and their handlers run against the trusted engine facade that bypasses RLS and FLS — so the invoke-time capability gate is the only boundary there is. Theduly_admincapability this card creates is what closes it. Treat #30 as part of this card's acceptance rather than a follow-up: an ungated bulk-create-duties-for-arbitrary-users action is not something to leave open across rounds.
Generated by Claude Code
Claim: PM loop round 3
Session:session_01SqkTcrxUFci7nqXdbBSe2p
Branch:claude/issue-8-security-model
Worktree:duly-issue-8
File surface:src/security/,src/actions/task.actions.ts+src/actions/catalog.actions.ts(requiredPermissionsonly, for #30/#40),docs/deployment/security.md,test/security.test.ts
Container & model: L,mode:subagent,model: opus
Clause-②: no
Serial constraints cleared: round 3 is #42 / #8 / #9 / #12. #42 ownssrc/actions/register-handlers.ts(a one-line wire) and this card touches only the action metadata files, not the registration function — disjoint. #30 and #40 are folded into this card's acceptance, not left as follow-ups: both sets of actions currently ship ungated and their handlers run on the RLS-bypassing facade.
Generated by Claude Code
{ "issue": 8, "status": "needs_decision", "branch": "claude/issue-8-security-model", "pr": "https://github.com/objectstack-ai/duly/pull/53", "premise_still_valid": false, "summary": "Three flat positions, three composed permission sets, capability gates on all five actions (#30 and #40 fully delivered and closed by the PR), and docs/deployment/security.md. Both product invariants are enforced and asserted across all three sets: duly_log_entry is readScope 'own' for every position including admin, and no set carries a write scope wider than 'own' on duly_task/duly_duty. Inheritance is structural — MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS — and the test asserts every non-overridden entry is the same object, so restating instead of inheriting fails a test. PREMISE FALSIFIED, and it is the card's central technical premise, repeated verbatim in the PM comment: the ADR-0057 depth scopes do NOT 'fall back to owner-only, silently, with no error' in an open-edition checkout. defineStack's validateHierarchyScopeCapability is a HARD ERROR on unit/unit_and_below/own_and_reports unless the stack declares requires:['hierarchy-security'] — it runs inside defineStack(), so it takes validate, build and the three tests that import the config. And the reason the card forbids that declaration is also false: measured with the capability declared, unit_and_below restored and security-enterprise absent, validate/test/build all exit 0 (kernel logged 'Bootstrap complete'); the provider check emits a WARNING, not a failure. The fix therefore looks like one line in objectstack.config.ts, which this card may not touch and which a rule written in four places forbids — so it is filed as duly#46 and the three affected grants ship at 'own' (identical to what the open edition would have resolved anyway, just honestly declared) recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions by the test. Second, separate wall: the card's duly_log_entry sharing rule is not expressible at all — sharing recipients are five STATIC principals and expandRecipient resolves rule.recipient_id once per rule, never per matched record, so 'the owner's manager' has nothing to resolve through; the nearest expressible recipient (position: duly_manager) would leak every marked entry to every manager in the tenant, so nothing is authored and the state fails closed. RLS is not a workaround despite the lint hint saying so: on a private object getReadFilter AND-composes RLS under plugin-sharing's owner filter, so a policy can only narrow. Filed upstream as objectstack#14103. Two deliberate deviations from the dispatch wording, both flagged in the PR: (1) the PR body opens 'Part of #8' rather than 'Fixes #8', because merging must not silently close a card with a live decision on it (standing clause outranks the per-card wording); Fixes #30 / Fixes #40 are as instructed and are genuinely complete. (2) objectstack.config.ts is untouched in the diff, but I DID mutate it twice under a restoring trap purely to measure the hierarchy deadlock — restored and verified byte-clean after each leg (git status empty, pristine 'requires' line back), and it is not in the 8-file diff. No other file-surface breach: the diff is exactly src/security/ (4 files), the requiredPermissions keys on the two action files (pure additions, no imports, no handler bodies, register-handlers.ts untouched), docs/deployment/security.md and test/security.test.ts.", "tests": "All four gates green at 8d5cc29 (branch tip), re-run on the restored tree AFTER the ablations; exit codes captured before any pipe, verdict lines quoted from each gate's own output. pnpm validate EXIT=0 -> '✓ Validation passed (330ms)' / 'Security: 3 Positions 3 Permissions'. pnpm typecheck EXIT=0 -> tsc --noEmit, no output. pnpm test EXIT=0 -> 'Test Files 9 passed (9)' / 'Tests 334 passed (334)' (was 278 on origin/main; test/security.test.ts adds 56). pnpm build EXIT=0 -> '✓ Build complete (603ms)' / 'Security: 3 Positions 3 Permissions'. git status clean at that sha. ABLATION 1 — widen duly_log_entry to readScope 'org' on the manager set. 'org' was chosen over 'unit_and_below' deliberately so the only red can come from my guard rather than from defineStack refusing to load. Predicted direction: red. Mutation confirmed on disk by grep of the injected anchor (count 1) plus a python assert count==1 on the exact replaced text; observed 'Tests 4 failed | 330 passed' with FAILs in exactly 'duly_log_entry must not leak > every set — admin included — reads own and only own', 'manager and admin inherit rather than restate > every non-overridden manager entry is the member entry itself', and both 'duly_manager/duly_admin · duly_log_entry declares readScope=own writeScope=own'. Restore leg verified: injected-marker grep back to 0. ABLATION 2 — delete requiredPermissions from duly_catalog_apply. Predicted direction: red. Observed 'Tests 2 failed | 332 passed', FAILs 'duly_catalog_apply requires duly.catalog.apply' and 'every action this app ships declares a gate — none left open'. HONEST DEFECT IN MY OWN PROBE: the shell marker I printed for this leg was mis-anchored — it grepped the bare capability string, which also occurs three times in prose comments, so it printed 3 where I had labelled it 'want 0'. The disk-landing proof for this leg is therefore the python assert count==1 on the exact 'requiredPermissions:' line before the replace, plus the targeted red; the grep reading is void, not evidence. Ablation 1's markers were clean (1 -> 0). No test file has a rebuild dependency here — this is a single-package app with no dist, tests import src directly, so there is no stale-artifact leg to prove. HIERARCHY MEASUREMENT (the premise falsification): run under a trap against objectstack.config.ts, mutation confirmed on disk (injected count 1, original count 0). With requires:['automation','hierarchy-security'] and readScope:'unit_and_below' restored on the manager grant, security-enterprise NOT installed: validate EXIT=0 '✓ Validation passed' + the warning 'Capability \"hierarchy-security\" is provided by @objectstack/security-enterprise'; test EXIT=0 'Test Files 8 passed (8) / Tests 278 passed (278)' with the kernel logging '✅ Bootstrap complete'; build EXIT=0 '✓ Build complete'. Bounds stated on #46 and in the PR: this covers objectstack validate, objectstack build and the in-process kernel boot the suite performs, not a production objectstack start or a cloud runtime. Both legs restored; git status verified empty afterwards. One process note worth recording: the FIRST hierarchy probe's restore leg silently failed on src/security/permission-sets.ts because it was untracked at the time and 'git checkout HEAD --' cannot restore a file HEAD does not have — the mutation survived into my working tree and I caught it only by re-grepping, not by the exit code (which was swallowed by '|| true'). Everything after that point was committed first so restores had a real source.", "open_questions": [ { "question": "The manager read depth. The card specifies readScope 'unit_and_below' for duly_manager on duly_task/duly_duty (and duly_admin on duly_task), and AGENTS.md rule 7 says to author it and let the open edition fall back silently. Measured, that is not what happens: defineStack refuses to load, and the prescribed remedy (requires:['hierarchy-security']) does NOT fail an open-edition boot as rule 7, objectstack.config.ts, the card and the PM comment all assert — it warns and everything stays green. The PR ships those three grants at 'own', fail-closed. What should the repo do?", "options": [ "A — Add 'hierarchy-security' to requires in objectstack.config.ts and correct the comment beside it (filed as duly#46). Authors the ADR-0057 scopes exactly as the card specifies; all four gates stay green; the silent open-edition fallback becomes a printed warning on every validate/build. Costs: one warning line per dev run, and an explicit declaration that Duly needs @objectstack/security-enterprise — which objectstack.config.ts's own security comment ALREADY calls 'a HARD product dependency'. Requires rewriting AGENTS.md rule 7, whose operative instruction rests on the falsified fact.", "B — Keep requires as-is and accept owner-only manager grants until a deployment takes the enterprise package. Zero change to the shared config, zero risk to the three parallel devs in this repo right now. Costs: the manager model — the substance of M2 — is declared narrower than the product, and every future card touching a depth scope hits the same wall and re-derives it. HIERARCHY_SCOPES_DEFERRED plus its test keep the debt visible and un-rottable, but it is still debt.", "C — Ask upstream for a way to declare a hierarchy scope that is intentionally unresolvable in the running edition (a soft/deferred declaration). Costs: blocks the manager model on a platform release, and on inspection the current platform behaviour is coherent rather than broken — refusing an undeclared hierarchy scope is exactly what prevents the silent degradation rule 7 tells devs to live with. I did not file this one, because filing against correct behaviour is noise." ], "recommendation": "A. Real business need: the manager view is not speculative surface — it is the reason duly_manager exists, and every 'Team' nav item in duly.app.ts points at it; B ships those screens knowingly empty for anyone who deploys with the enterprise package. Long-term soundness: A is the contract-first answer — the app's dependency becomes a declaration the platform can check rather than a comment nobody can act on, which is exactly ADR-0049's declared-equals-enforced direction, and B leaves the authored metadata permanently understating the product. Making AI-written metadata hard to get wrong: this matters most and it decides it. Under B the next agent who types a depth scope meets a hard load failure that four repo documents tell them cannot happen, and the two nearest escapes are both wrong — 'org' validates cleanly and leaks the whole tenant, and deleting the scope validates cleanly and silently narrows. Under A the scope is authorable, the platform's own warning names the missing package, and the trapdoor closes. Startup scope discipline: A is one line plus a comment correction, adds no capability surface and no code, and does not expand the product — it makes an existing dependency legible. The one thing A must not do quietly is change the shared config mid-round: it is a collision file with three devs in it, and it overturns a rule written in four places, so it wants its own change with the maintainer's ruling on it — which is duly#46, filed unassigned and ready to dispatch." }, { "question": "duly_log_entry's manager visibility, and duly_assignment's assignee visibility. Neither is expressible: sharing recipients are static principals resolved once per rule, and RLS cannot widen a private object because it is AND-composed under plugin-sharing's owner filter. Filed upstream as objectstack#14103. What should the product do while it is open?", "options": [ "A — Ship fail-closed, as the PR does. LogEntry.visibility keeps its 'My manager' option, stores correctly, and grants nothing. Documented in docs/deployment/security.md and guarded by a test asserting no sharing rule targets duly_log_entry.", "B — Hide the 'My manager' option from the picker until the grant exists, so the UI stops promising something the platform does not do.", "C — Author sharedWith { type: 'position', value: 'duly_manager' } as an interim. Rejected outright and not implemented — it shares every marked entry with every manager in the tenant, which is precisely the skip-level disclosure the invariant exists to prevent." ], "recommendation": "A, with B worth a separate small card if the maintainer wants the UI to stop over-promising. Real business need: the manager-visible log entry is a stated product behaviour, so the field should not be deleted; it is the grant that is missing, not the intent. Long-term soundness: the option is the anchor the upstream fix lands against, and removing it would make the eventual re-add a second product decision. Making AI-written metadata hard to get wrong: this is the strongest argument for keeping the empty sharing-rules.ts file rather than deleting it — the leaky recipient lints clean, validates clean and reads as the obvious answer, so the measurement has to sit where the next author will look. Startup scope discipline: A costs nothing to ship and nothing to carry; B is a small UI change that is only worth doing if 14103 looks far off. C is not on the table at any price." } ], "out_of_scope_findings": [ "filed as objectstack-ai/objectstack#14103: sharing rules cannot name a record-relative recipient (the owner's manager, or the value of a user field on the matched record), and RLS cannot substitute because it is AND-composed under the private-object owner filter — hit twice in this one app (duly_log_entry.visibility, duly_assignment.assignees)", "filed as duly#46: AGENTS.md rule 7 and objectstack.config.ts claim declaring hierarchy-security fails an open-edition boot — measured false (validate/test/build all exit 0, kernel boots, one warning) — and that false claim is what blocks the manager read scopes; docs/product/data-model.md's 'Security posture' section carries the same claim and is outside this card's file surface", "filed as duly#50: duly_duty.source defaults to 'catalog', so a hand-created self-declared duty is born into the governed, scoreable set — the inverse of the invariant that self duties are surfaced but never scored, and it also exposes the duty to duly_catalog_sync's cadence rewrite" ] }
Generated by Claude Code
os-dev-report
{ "issue": 8, "status": "needs_decision", "branch": "claude/issue-8-security-model", "pr": "https://github.com/objectstack-ai/duly/pull/53", "premise_still_valid": false, "summary": "Three flat positions, three composed permission sets, capability gates on all five actions (#30 and #40 fully delivered and closed by the PR), and docs/deployment/security.md. Both product invariants are enforced and asserted across all three sets: duly_log_entry is readScope 'own' for every position including admin, and no set carries a write scope wider than 'own' on duly_task/duly_duty. Inheritance is structural — MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS — and the test asserts every non-overridden entry is the same object, so restating instead of inheriting fails a test. PREMISE FALSIFIED, and it is the card's central technical premise, repeated verbatim in the PM comment: the ADR-0057 depth scopes do NOT 'fall back to owner-only, silently, with no error' in an open-edition checkout. defineStack's validateHierarchyScopeCapability is a HARD ERROR on unit/unit_and_below/own_and_reports unless the stack declares requires:['hierarchy-security'] — it runs inside defineStack(), so it takes validate, build and the three tests that import the config. And the reason the card forbids that declaration is also false: measured with the capability declared, unit_and_below restored and security-enterprise absent, validate/test/build all exit 0 (kernel logged 'Bootstrap complete'); the provider check emits a WARNING, not a failure. The fix therefore looks like one line in objectstack.config.ts, which this card may not touch and which a rule written in four places forbids — so it is filed as duly#46 and the three affected grants ship at 'own' (identical to what the open edition would have resolved anyway, just honestly declared) recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions by the test. Second, separate wall: the card's duly_log_entry sharing rule is not expressible at all — sharing recipients are five STATIC principals and expandRecipient resolves rule.recipient_id once per rule, never per matched record, so 'the owner's manager' has nothing to resolve through; the nearest expressible recipient (position: duly_manager) would leak every marked entry to every manager in the tenant, so nothing is authored and the state fails closed. RLS is not a workaround despite the lint hint saying so: on a private object getReadFilter AND-composes RLS under plugin-sharing's owner filter, so a policy can only narrow. Filed upstream as objectstack#14103. Two deliberate deviations from the dispatch wording, both flagged in the PR: (1) the PR body opens 'Part of #8' rather than the closing keyword, because merging must not silently close a card with a live decision on it (standing clause outranks the per-card wording); #30 and #40 are closed by the PR as instructed and are genuinely complete. (2) objectstack.config.ts is untouched in the diff, but I DID mutate it twice under a restoring trap purely to measure the hierarchy deadlock — restored and verified byte-clean after each leg (git status empty, pristine 'requires' line back), and it is not in the 8-file diff. No other file-surface breach: the diff is exactly src/security/ (4 files), the requiredPermissions keys on the two action files (pure additions, no imports, no handler bodies, register-handlers.ts untouched), docs/deployment/security.md and test/security.test.ts.", "tests": "All four gates green at 8d5cc29 (branch tip), re-run on the restored tree AFTER the ablations; exit codes captured before any pipe, verdict lines quoted from each gate's own output. pnpm validate EXIT=0 -> '✓ Validation passed (330ms)' / 'Security: 3 Positions 3 Permissions'. pnpm typecheck EXIT=0 -> tsc --noEmit, no output. pnpm test EXIT=0 -> 'Test Files 9 passed (9)' / 'Tests 334 passed (334)' (was 278 on origin/main; test/security.test.ts adds 56). pnpm build EXIT=0 -> '✓ Build complete (603ms)' / 'Security: 3 Positions 3 Permissions'. git status clean at that sha. ABLATION 1 — widen duly_log_entry to readScope 'org' on the manager set. 'org' was chosen over 'unit_and_below' deliberately so the only red can come from my guard rather than from defineStack refusing to load. Predicted direction: red. Mutation confirmed on disk by grep of the injected anchor (count 1) plus a python assert count==1 on the exact replaced text; observed 'Tests 4 failed | 330 passed' with FAILs in exactly 'duly_log_entry must not leak > every set — admin included — reads own and only own', 'manager and admin inherit rather than restate > every non-overridden manager entry is the member entry itself', and both 'duly_manager/duly_admin · duly_log_entry declares readScope=own writeScope=own'. Restore leg verified: injected-marker grep back to 0. ABLATION 2 — delete requiredPermissions from duly_catalog_apply. Predicted direction: red. Observed 'Tests 2 failed | 332 passed', FAILs 'duly_catalog_apply requires duly.catalog.apply' and 'every action this app ships declares a gate — none left open'. HONEST DEFECT IN MY OWN PROBE: the shell marker I printed for this leg was mis-anchored — it grepped the bare capability string, which also occurs three times in prose comments, so it printed 3 where I had labelled it 'want 0'. The disk-landing proof for this leg is therefore the python assert count==1 on the exact 'requiredPermissions:' line before the replace, plus the targeted red; the grep reading is void, not evidence. Ablation 1's markers were clean (1 -> 0). No test file has a rebuild dependency here — this is a single-package app with no dist, tests import src directly, so there is no stale-artifact leg to prove. HIERARCHY MEASUREMENT (the premise falsification): run under a trap against objectstack.config.ts, mutation confirmed on disk (injected count 1, original count 0). With requires:['automation','hierarchy-security'] and readScope:'unit_and_below' restored on the manager grant, security-enterprise NOT installed: validate EXIT=0 '✓ Validation passed' + the warning 'Capability hierarchy-security is provided by @objectstack/security-enterprise'; test EXIT=0 'Test Files 8 passed (8) / Tests 278 passed (278)' with the kernel logging '✅ Bootstrap complete'; build EXIT=0 '✓ Build complete'. Bounds stated on #46 and in the PR: this covers objectstack validate, objectstack build and the in-process kernel boot the suite performs, not a production objectstack start or a cloud runtime. Both legs restored; git status verified empty afterwards. One process note worth recording: the FIRST hierarchy probe's restore leg silently failed on src/security/permission-sets.ts because it was untracked at the time and 'git checkout HEAD --' cannot restore a file HEAD does not have — the mutation survived into my working tree and I caught it only by re-grepping, not by the exit code (which was swallowed by '|| true'). Everything after that point was committed first so restores had a real source.", "open_questions": [ { "question": "The manager read depth. The card specifies readScope 'unit_and_below' for duly_manager on duly_task/duly_duty (and duly_admin on duly_task), and AGENTS.md rule 7 says to author it and let the open edition fall back silently. Measured, that is not what happens: defineStack refuses to load, and the prescribed remedy (requires:['hierarchy-security']) does NOT fail an open-edition boot as rule 7, objectstack.config.ts, the card and the PM comment all assert — it warns and everything stays green. The PR ships those three grants at 'own', fail-closed. What should the repo do?", "options": [ "A — Add 'hierarchy-security' to requires in objectstack.config.ts and correct the comment beside it (filed as duly#46). Authors the ADR-0057 scopes exactly as the card specifies; all four gates stay green; the silent open-edition fallback becomes a printed warning on every validate/build. Costs: one warning line per dev run, and an explicit declaration that Duly needs @objectstack/security-enterprise — which objectstack.config.ts's own security comment ALREADY calls 'a HARD product dependency'. Requires rewriting AGENTS.md rule 7, whose operative instruction rests on the falsified fact.", "B — Keep requires as-is and accept owner-only manager grants until a deployment takes the enterprise package. Zero change to the shared config, zero risk to the three parallel devs in this repo right now. Costs: the manager model — the substance of M2 — is declared narrower than the product, and every future card touching a depth scope hits the same wall and re-derives it. HIERARCHY_SCOPES_DEFERRED plus its test keep the debt visible and un-rottable, but it is still debt.", "C — Ask upstream for a way to declare a hierarchy scope that is intentionally unresolvable in the running edition (a soft/deferred declaration). Costs: blocks the manager model on a platform release, and on inspection the current platform behaviour is coherent rather than broken — refusing an undeclared hierarchy scope is exactly what prevents the silent degradation rule 7 tells devs to live with. I did not file this one, because filing against correct behaviour is noise." ], "recommendation": "A. Real business need: the manager view is not speculative surface — it is the reason duly_manager exists, and every 'Team' nav item in duly.app.ts points at it; B ships those screens knowingly empty for anyone who deploys with the enterprise package. Long-term soundness: A is the contract-first answer — the app's dependency becomes a declaration the platform can check rather than a comment nobody can act on, which is exactly ADR-0049's declared-equals-enforced direction, and B leaves the authored metadata permanently understating the product. Making AI-written metadata hard to get wrong: this matters most and it decides it. Under B the next agent who types a depth scope meets a hard load failure that four repo documents tell them cannot happen, and the two nearest escapes are both wrong — 'org' validates cleanly and leaks the whole tenant, and deleting the scope validates cleanly and silently narrows. Under A the scope is authorable, the platform's own warning names the missing package, and the trapdoor closes. Startup scope discipline: A is one line plus a comment correction, adds no capability surface and no code, and does not expand the product — it makes an existing dependency legible. The one thing A must not do quietly is change the shared config mid-round: it is a collision file with three devs in it, and it overturns a rule written in four places, so it wants its own change with the maintainer's ruling on it — which is duly#46, filed unassigned and ready to dispatch." }, { "question": "duly_log_entry's manager visibility, and duly_assignment's assignee visibility. Neither is expressible: sharing recipients are static principals resolved once per rule, and RLS cannot widen a private object because it is AND-composed under plugin-sharing's owner filter. Filed upstream as objectstack#14103. What should the product do while it is open?", "options": [ "A — Ship fail-closed, as the PR does. LogEntry.visibility keeps its 'My manager' option, stores correctly, and grants nothing. Documented in docs/deployment/security.md and guarded by a test asserting no sharing rule targets duly_log_entry.", "B — Hide the 'My manager' option from the picker until the grant exists, so the UI stops promising something the platform does not do.", "C — Author sharedWith { type: 'position', value: 'duly_manager' } as an interim. Rejected outright and not implemented — it shares every marked entry with every manager in the tenant, which is precisely the skip-level disclosure the invariant exists to prevent." ], "recommendation": "A, with B worth a separate small card if the maintainer wants the UI to stop over-promising. Real business need: the manager-visible log entry is a stated product behaviour, so the field should not be deleted; it is the grant that is missing, not the intent. Long-term soundness: the option is the anchor the upstream fix lands against, and removing it would make the eventual re-add a second product decision. Making AI-written metadata hard to get wrong: this is the strongest argument for keeping the empty sharing-rules.ts file rather than deleting it — the leaky recipient lints clean, validates clean and reads as the obvious answer, so the measurement has to sit where the next author will look. Startup scope discipline: A costs nothing to ship and nothing to carry; B is a small UI change that is only worth doing if 14103 looks far off. C is not on the table at any price." } ], "out_of_scope_findings": [ "filed as objectstack-ai/objectstack#14103: sharing rules cannot name a record-relative recipient (the owner's manager, or the value of a user field on the matched record), and RLS cannot substitute because it is AND-composed under the private-object owner filter — hit twice in this one app (duly_log_entry.visibility, duly_assignment.assignees)", "filed as duly#46: AGENTS.md rule 7 and objectstack.config.ts claim declaring hierarchy-security fails an open-edition boot — measured false (validate/test/build all exit 0, kernel boots, one warning) — and that false claim is what blocks the manager read scopes; docs/product/data-model.md's 'Security posture' section carries the same claim and is outside this card's file surface", "filed as duly#50: duly_duty.source defaults to 'catalog', so a hand-created self-declared duty is born into the governed, scoreable set — the inverse of the invariant that self duties are surfaced but never scored, and it also exposes the duty to duly_catalog_sync's cadence rewrite" ] }Supersedes the previous comment on this issue, which carried the marker as an HTML comment: the GitHub body sanitizer stripped it on write, so that copy is invisible to a marker scan. Same report, marker as literal text. Verified by reading the stored body back — the earlier one now begins with a blank line, this one begins with
os-dev-report.
Generated by Claude Code
ACCEPT the PR; the card stays open on its decision. PM review of #53, round 3.
Gates re-run with
origin/mainmerged: all fourEXIT=0, 370 tests,Security: 3 Positions 3 Permissions. CIverifysuccesson8d5cc29. File surface held exactly.Ablation reproduced: widening
duly_log_entrytoreadScope: 'org'on the member grant reds four tests, including all three per-set assertions and× every set — admin included — reads own and only own. Because inheritance is structural —MANAGER_OBJECTSspreadsMEMBER_OBJECTS,ADMIN_OBJECTSspreadsMANAGER_OBJECTS— a single widening propagates to every set rather than needing three separate guards. That is the right construction.Opening the PR body with
Part of #8rather than the closing keyword was correct and I want it on the record: merging must not silently close a card with a live decision on it. #30 and #40 are genuinely complete and are closed by it.You falsified my premise, and I verified it
I went to the platform source rather than take it on the measurement.
packages/spec/src/stack.zod.ts,validateHierarchyScopeCapability:A stack that uses one MUST declare
requires: ['hierarchy-security']; otherwise the open runtime would silently fail closed to owner-only (the metadata would lie, ADR-0049). This makes that an authoring-time error instead.So the silent fallback that
AGENTS.mdrule 7,objectstack.config.ts,docs/product/data-model.mdand my own dispatch comment all describe is the behaviour the platform deliberately closed. It is a hard authoring error now, exactly as you measured.And the second half is worse, because I had the evidence in hand. Earlier this session I declared
requires: ['hierarchy-security']and read the output myself — it was a warning, naming the package to install. I then removed the declaration and wrote "Declaring the capability would make an open-edition boot fail" into the config, contradicting the output I had just seen. Four documents now carry a claim that a five-minute measurement disproves, and the only reason it surfaced is that you refused to route around a hard error you were told could not happen.Adjudicated: option A, via #46 — which I am dispatching next. Your reasoning on the third axis decides it: under B the next agent to type a depth scope meets a hard load failure that four repo documents say is impossible, and both nearby escapes are wrong —
orgvalidates cleanly and leaks the tenant, deleting the scope validates cleanly and silently narrows. That is a trapdoor, and closing it is worth one warning line per dev run.Keeping it out of this PR was also right.
objectstack.config.tsis the collision file, three devs were in the repo, and A overturns a rule written in four places — it wants its own change with a ruling attached.The second wall
objectstack#14103 — sharing recipients are five static principals resolved once per rule, so "the owner's manager" has nothing to resolve through, and RLS cannot substitute because it is AND-composed under the private-object owner filter and can only narrow. Hit twice in one app.
Recommendation A accepted: ship fail-closed, keep the
visibilityoption, keep the emptysharing-rules.tswith the measurement in it. Your argument for keeping the file rather than deleting it is the right one — the leaky recipient (position: duly_manager, which would disclose every marked entry to every manager in the tenant) lints clean, validates clean and reads as the obvious answer, so the reasoning has to sit exactly where the next author will look for it. Rejecting C outright was correct: that is the skip-level disclosure the invariant exists to prevent, and it is not on the table at any price.B (hiding the option until the grant exists) gets its own small card only if #14103 looks far off.
Your own process note
The first hierarchy probe's restore silently failed because
src/security/permission-sets.tswas untracked at the time, andgit checkout HEAD --cannot restore a fileHEADdoes not have — caught by re-grepping, not by the exit code, which|| truehad swallowed. Worth recording alongside the index-versus-HEAD trap from #5: restore mechanisms that report success while giving back the wrong content are the recurring hazard in this workflow, and "commit first" is the rule that closes all of them.
Generated by Claude Code
Every object already states its OWD; this is the layer that opens the right doors on top of
private.Files you own
src/security/positions.ts,src/security/permission-sets.ts,src/security/sharing-rules.ts(new), pushed into the three arrays insrc/security/index.tstest/security.test.ts(new)Positions (flat — ADR-0090 D3, there is no position tree)
duly_memberduly_managerduly_adminPermission sets
Author with
definePermissionSet({ name, label, objects: { … }, fields, tabPermissions, systemPermissions }).duly_memberduly_task: create/read/edit,readScope: 'own',writeScope: 'own'duly_duty: read own; create own (source: 'self')duly_log_entry: full control of own,readScope: 'own'duly_catalog_item: read onlyduly_assignment: read the ones they are onduly_manager— member, plusduly_task,duly_duty:readScope: 'unit_and_below',writeScopestays'own'. A manager reads everything below them and writes none of it. This is not an oversight to close later: the product's position is that a manager's only write is assigning, and the permission set is where that gets enforced rather than merely designed.duly_assignment: create/edit ownduly_admin— manager, plusduly_catalog_item: full controlduly_duty:readScope: 'org', and edit for correctionsduly_log_entry— the one that must not leakreadScope: 'own'for every position including admin. The only widening is the record's ownvisibility: 'manager', which a criteria sharing rule grants to that person's manager and nobody else. No unit scope, no org scope, no admin override.A log people believe their skip-level can read is a log nobody keeps, and the module stops producing the record it exists to produce.
Sharing rules
defineSharingRule({ type: 'criteria', … }):duly_log_entrywherevisibility = 'manager'→ recipient: the owner's managerduly_catalog_itemis alreadypublic_read; no rule neededThe open-edition caveat — read before you debug
readScope: 'own_and_reports' | 'unit' | 'unit_and_below' | 'org'(ADR-0057) are resolved by@objectstack/security-enterprise, which enterprise deployments carry. This repo runs on the open edition, where they fall back to owner-only, silently, with no error.That means a manager view in your dev environment will show you only your own rows. That is expected, not a bug — do not chase it, do not build an application-level fallback, and do not add
hierarchy-securitytorequires(it would fail an open-edition boot). Write the metadata correctly for the enterprise runtime and assert the authored scopes in tests rather than the resolved rows.Acceptance
readScope/writeScopeper object intest/security.test.tsduly_log_entrya scope wider thanownduly_task/duly_dutywider thanownduly_managerandduly_admininherit rather than restateduly_member's grantsdocs/product/data-model.mdor a newdocs/deployment/security.mdstates the enterprise-edition requirement for a real rolloutGates
pnpm validate && pnpm typecheck && pnpm test && pnpm build.