Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Declare the security model, and gate all five actions - #53

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model
Sep 1, 2026
Merged

Declare the security model, and gate all five actions#53
os-warren merged 1 commit into
mainfrom
claude/issue-8-security-model

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Part of #8
Fixes#30
Fixes#40

Three flat positions, three composed permission sets, capability gates on all five actions, and a deployment page that says what a rollout still has to do by hand.

The first line is Part of, not a closing keyword, and that is deliberate: two things the card specifies are not shippable on protocol 17.2.0 (below), and one of them needs a maintainer ruling recorded on #8 before that card is closed. #30 and #40 are complete and do close here.

Gates, all four green at 8d5cc29 — the tip of this branch, re-run on the restored tree after the ablations below:

gateverdict line
pnpm validate✓ Validation passed (330ms) · Security: 3 Positions 3 Permissions
pnpm typechecktsc --noEmit, exit 0, no output
pnpm testTest Files 9 passed (9) · Tests 334 passed (334)
pnpm build✓ Build complete (603ms)

The model

duly_memberduly_managerduly_admin
duly_taskcreate/read/edit · read own · write owninheritedinherited
duly_dutycreate/read · read own · write owninherited+edit · read org · write own
duly_log_entryfull control · read own · write owninheritedinherited
duly_catalog_itemreadinherited+create/edit/delete · write org
duly_assignmentread · read own+create/edit · write owninherited
Capabilitiesduly.task.update_statusinherited+duly.catalog.apply, duly.catalog.sync

"Inherited" is literal, not descriptive: MANAGER_OBJECTS spreads MEMBER_OBJECTS, ADMIN_OBJECTS spreads MANAGER_OBJECTS, and the test asserts every non-overridden entry is the same object. A grant is written once, so nobody can widen the work log for managers by editing a copy — there is no copy.

Each set is self-contained, so binding one set to one position is a correct deployment.

The two invariants, enforced rather than intended

The work log is closed to everyone but its owner, administrators included. No depth scope, no viewAllRecords, no sharing rule, walked across all three sets. No set carries a write scope wider than own on duly_task / duly_duty — every widening in this PR is on the read axis, and the test also proves that the only write bit the manager set adds anywhere is duly_assignment.

duly_admin.duly_duty is the one entry worth reading twice: it gets allowEditand keeps writeScope: 'own'. That is the card's acceptance line beating its permission-set line, and the bit is not inert — an administrator can correct their own duties, which a member cannot. The org-wide correction path is deliberately elsewhere: duly_catalog_sync, which is bounded to cadence fields, capability-gated, and reportable. A correction typed into someone else's duty record is none of those.

Deliberate non-obvious calls

  • duly_catalog_item gets writeScope: 'org' on the admin set, and no readScope anywhere.public_read is read-open but write-ownedbuildWriteFilter applies to private and public_read alike — so without the write depth an administrator could only edit catalog items they personally created. Conversely buildReadFilter returns null before depth is consulted for any non-private object, so a readScope there would be inert and is not authored.
  • duly.catalog.apply and duly.catalog.sync are two capabilities, both granted to duly_admin, answering the question Gate the catalog actions with requiredPermissionsduly_catalog_apply is currently ungated #30 raises. Nothing is harder to deploy, but a customer who wants an onboarding administrator who cannot rewrite the org's cadence can now express it.
  • No isDefault, no fields block, no adminScope, no '*' wildcard.isDefault is not available: the ADR-0090 D5/D9 anchor tier refuses any set carrying systemPermissions or a delete bit, and duly_member carries both. FLS would only restate readonly flags the objects already enforce.
  • The action files hard-code their capability strings rather than importing the constants, to keep to the requiredPermissions-only file surface. The link is held by two independent checks instead: the capability-reference-unknown author-time rule, and a test asserting every required capability is granted by a set in this package.

⛔ Two things the card asks for that this platform version cannot express

Both fail closed. Neither is approximated.

1. No sharing rule can name the record owner's manager — objectstack#14103

ShareRecipientType is five static principals, and plugin-sharing's expandRecipient resolves rule.recipient_id once per rule, never per matched record. The predicate half (record.visibility == "manager") lowers fine; the recipient half has nothing to resolve through.

The nearest expressible recipient, position: 'duly_manager', would hand every marked entry to every manager in the tenant — the disclosure the invariant exists to prevent — so it is not authored as a stopgap.

RLS is not a way round it either, despite sharing-rule-runtime-variable-condition's hint recommending exactly that. On a private object the layers are AND-composed, not OR-composed:

// plugin-security getReadFilterreturnandComposeLayers(andComposeLayers(filter,cbpFilter),sharingFilter)??void0;

and buildReadFilter returns ownerMatch (OR'd only with the caller's own sys_record_share grants). An RLS policy can therefore only narrow a private object's readable set. Widening has two doors — the ADR-0057 depth scopes and a sys_record_share row — and both are shut for a record-relative recipient.

duly_assignment hits the same wall: "readable by the people it is addressed to" is the same shape. Assignees still see their own fanned-out duly_task, which is the row they work.

src/security/sharing-rules.ts is an empty array with the whole measurement on it. The file exists precisely so the next author does not rediscover the wall and author their way past it with the recipient that lints clean.

2. A hierarchy read depth cannot be authored here — tracked as #46

duly_manager on duly_task / duly_duty and duly_admin on duly_task should read unit_and_below. They ship at own.

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. This is not the "silent fallback to owner-only" AGENTS.md rule 7 describes — nothing silent happens; the config will not load.

The premise blocking the prescribed fix is measurably false. Rule 7, objectstack.config.ts, the card and its PM comment all state that declaring hierarchy-security "would fail an open-edition boot". Measured on this checkout with the capability declared, unit_and_below restored, and @objectstack/security-enterprise not installed:

gateexitresult
pnpm validate0✓ Validation passed + one provider warning
pnpm test0Tests 278 passed (278), kernel logged ✅ Bootstrap complete
pnpm build0✓ Build complete

So the one-line fix appears to work. It is not taken here: objectstack.config.ts is outside this card's file surface, it is the collision file AGENTS.md rule 2 reserves, and overturning a rule written in four places belongs on its own change. #46 carries the full measurement and its bounds.

own is also exactly what an open-edition runtime would have resolvedunit_and_below to, so nothing about today's behaviour differs — only the honesty of the declaration. The three grants are recorded in HIERARCHY_SCOPES_DEFERRED and pinned in both directions: widen a grant without deleting its row and the test fails; delete a row without widening the grant and the test fails.


Ablations — the new tests do fail when they should

Both run on the committed tree with a restoring trap, mutation confirmed on disk before the run and absence confirmed after. Predicted direction for both was red, and both went red in exactly the named places.

Widen duly_log_entry to readScope: 'org' on the manager set (org chosen over unit_and_below on purpose — it is authorable, so the only red can come from the guard rather than from defineStack refusing to load):

injected marker count: 1 → Tests 4 failed | 330 passed
FAIL duly_log_entry must not leak > every set — admin included — reads own and only own
FAIL manager and admin inherit rather than restate > every non-overridden manager entry …
FAIL permission sets … > duly_manager · duly_log_entry declares readScope=own writeScope=own
FAIL permission sets … > duly_admin · duly_log_entry declares readScope=own writeScope=own
restored marker count: 0

Delete requiredPermissions from duly_catalog_apply:

Tests 2 failed | 332 passed
FAIL action capability gates > duly_catalog_apply requires duly.catalog.apply
FAIL action capability gates > every action this app ships declares a gate — none left open

One honest note on the second: the shell marker I printed for it was mis-anchored — it grepped the bare capability string, which also appears three times in prose, so it read 3 where I had written "want 0". The disk-landing proof there is the Python assert count == 1 on the exact requiredPermissions: line before the replace, plus the targeted red. The first ablation's markers were clean (1 → 0).

The own_and_reports / unit_and_below half of the hierarchy finding was measured the same way, against objectstack.config.ts under a trap; that file is untouched in this diff (git status clean, verified after each leg).


Also filed

Files

src/security/positions.ts, src/security/permission-sets.ts, src/security/sharing-rules.ts (new) · src/security/index.ts (barrel) · src/actions/catalog.actions.ts, src/actions/task.actions.ts (requiredPermissions keys only — no imports added, no handler bodies, no register-handlers.ts) · docs/deployment/security.md (new) · test/security.test.ts (new). objectstack.config.ts untouched.


Generated by Claude Code

Three flat positions, three composed permission sets, no sharing rules — and
`requiredPermissions` on every action, which closes#30 and #40.
The work log is closed to everyone but its owner, administrators included, and
no set carries a write scope wider than `own` on `duly_task` / `duly_duty`.
Both are asserted across all three sets rather than left to review.
Two things the card asked for are not shippable on protocol 17.2.0 and are
documented rather than approximated: a sharing rule cannot name the record
owner's manager (objectstack#14103), and a hierarchy read depth cannot be
authored without a capability this package may not declare (#46). Both fail
closed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant

@os-warren