Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading
, '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
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions .changeset/approval-auto-cancel-on-record-delete.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
---
'@objectstack/spec': minor
'@objectstack/plugin-approvals': minor
---

fix(approvals): a deleted record's pending approvals auto-cancel instead of stranding in the inbox (#13568)

Deleting a record left every `pending` approval it had opened sitting in the
approvers' inbox — counted in the pending total, openable, and pointing at a
`record_id` that resolves to nothing. Nothing about it was module-specific:
an approval node that declares `lockRecord` blocks the EDIT, so "delete and
recreate" is the only route left to an author who needs to fix a submitted
record, and every such delete added another orphan. Maintainer ruling
2026-08-31 (`总监席第 5 场决裁批 #5`, verbatim 「同意」): pending requests
auto-cancel on record delete — status `cancelled` plus a machine-readable
reason, rows KEPT for audit, out of the pending count and the inbox's default
view.

**Graded `minor`, and deliberately not `patch`.** The repair itself is a
defect fix, but it lands by WIDENING two published vocabularies and adding a
declared column, and this repo's convention grades a shipped service's
accept-set/behaviour move as `minor`. **No `BREAKING` banner**: nothing is
narrowed and no metadata that used to be accepted is now refused — the one
consequence a consumer can feel is that `ApprovalStatus` and
`ApprovalActionKind` each gained a member, so an exhaustive `switch` with no
default, or a `satisfies Record<ApprovalStatus, …>` map outside this repo,
now has a case to add. That is the same shape `returned` had when ADR-0044
landed it.

**Spec (`@objectstack/spec/contracts/approval-service`)**

- `APPROVAL_STATUSES` gains `cancelled` (+ its `APPROVAL_STATUS_LABELS`
entry). Its own terminal state rather than a re-use of `recalled`: a recall
is an ACT by the submitter, and filing a platform-initiated void as one
attributes a withdrawal to a person who never performed it.
- New `APPROVAL_CANCEL_REASONS` / `ApprovalCancelReason` /
`APPROVAL_CANCEL_REASON_LABELS`, single entry `record_deleted`. A
VOCABULARY, not free text, because the reason has a non-human consumer (the
inbox and the tombstone presentation branch on it) — and a CLASS, per the
ruling's wording, so the next platform-initiated cancellation cause extends
this list instead of minting a second terminal status for itself.
- `APPROVAL_ACTION_KINDS` gains `cancel` — the only kind with no human actor,
by construction.
- `ApprovalRequestRow.cancel_reason` declared, optional-nullable.

**Plugin (`@objectstack/plugin-approvals`)**

- `sys_approval_request.cancel_reason`, a select derived from the contract
vocabulary and never re-typed (the #3786 rule the `status` column already
follows). On the row rather than on the audit entry, so a plain list view
can read WHY without joining the append-only action log.
- `bindRecordDeleteCancelHook` — a GLOBAL `afterDelete` registration beside
the existing global record-lock hook, so one platform-level linkage covers
every "approval + `lockRecord`" object at once. It needs no row-set
plumbing: the engine binds the deleted row's pre-image on the by-id path
and fans `afterDelete` out per matched row on a predicate delete, so a bulk
delete is covered by the same handler. The approvals tables are excluded at
registration, so they do not pay the delete-side pre-image read.
- `ApprovalService.cancelForDeletedRecord` writes the transition: one
append-only `sys_approval_action` row (`action: 'cancel'`, no actor),
`status: 'cancelled'` + `cancel_reason: 'record_deleted'` +
`completed_at`, and a `sys_approval_approver` index clear — that last one
is not optional garnish, it is what actually empties the inbox, because the
approver filter resolves through that index rather than through `status`.
- The `Completed` list view now includes `cancelled`, so a kept audit row is
visible in the one curated terminal view rather than only under `All`.
- ⛔ **No flow resume and no status mirror-back.** A cancellation is a status
write plus a reason, not a decision, so there is no branch to resume down.
The mirror-back is skipped by construction rather than by a swallowed
error: it is an `update_record` against the row that was just deleted — the
exact write this card's forensics caught failing elsewhere. The suspended
run the request gated is reported at `warn` with its id and otherwise left
alone; what becomes of it belongs to the automation service.
- ⛔ **The delete is never refused.** The "forbid delete while an approval is
pending" direction was vetoed in the same ruling — `lockRecord` already
blocks the edit, and blocking the delete too locks an author onto a record
they cannot fix. Nothing in the hook throws; a failure degrades to the
pre-existing state (the stale row) and is logged.
- Terminal rows are untouched. `approved` / `rejected` / `recalled` /
`returned` requests about the deleted record keep their recorded outcome —
history stays history, and rendering their now-dead record reference is a
separate console-side change.

zh-CN / ja-JP / es-ES bundles carry authored translations for the new leaves
(已作废 / 無効化済み / Anulada), not source fills.
6 changes: 3 additions & 3 deletions content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -143,9 +143,9 @@ The largest single consumer — **20 of the 109 sites**.

| # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor |
|:--|:---|:---|:---|:---|
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:333` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:440` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:931`, `:1040`, `:2997`, `:3143`, `:3310`, `:3381`, `:3570`, `:3610` |
| 40 | **Approval record lock released** — a locked record is writable | plugin-approvals | Get: engine self-writes (the status mirror) pass. Lose: the lock that stops edits while an approval is live. Note there is deliberately **no admin exemption** here — only `isSystem` | `lifecycle-hooks.ts:347` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 42 | Approval actor / submitter / pending-approver checks bypassed (8 sites) | plugin-approvals | Get: approve, reject, recall, reassign without being a pending approver or the submitter | `plugin-approvals/src/approval-service.ts:950`, `:1059`, `:3163`, `:3309`, `:3476`, `:3547`, `:3736`, `:3776` |
| 43 | Saved-report ownership is **assignable**, and an update may reassign it | plugin-reports | Get: `ownerId` from input is honoured. A non-system caller always owns what it creates and can never reassign | `plugin-reports/src/report-service.ts:404`, `:425` |
| 44 | Saved-report access / export / mutation gates bypassed | plugin-reports | Get: read, bulk-export and overwrite any report | `plugin-reports/src/report-service.ts:343`, `:372`, `:447`, `:684` |
| 45 | Attachment access hooks return early (insert + update + delete, and the read AST) | service-storage | Lose: attachment visibility scoping | `attachment-access-hooks.ts:300`, `:349`, `:448`, `:524` |
Expand Down
28 changes: 14 additions & 14 deletions content/docs/permissions/tenant-audit-census.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,7 +98,7 @@ are reported as `undecidable` rather than assumed either way.

The same holds twice over for the context. An options argument spelled as a
literal can be read; one spelled `options`, `{ ...opts }`, or handed through a
forwarding shim cannot, and **67 of the 215 sites are spelled that way**. A
forwarding shim cannot, and **67 of the 217 sites are spelled that way**. A
context resolved from an inline literal or a local `const` can be tested for
`isSystem`; one arriving from a helper call cannot.

Expand DownExpand Up@@ -147,10 +147,10 @@ reproduce them. Where it disagrees, it disagrees on the page:

| carried figure | where it survives | this census |
| :--- | :--- | ---: |
| 175 write call sites | quoted in the merged changeset | **215** |
| 175 write call sites | quoted in the merged changeset | **217** |
| 24 carrying no tenant context | quoted in the merged changeset | **9** provable and tenancy-enabled; **32** more whose options argument is unreadable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **143 of 215** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 97 decidably elevated, 0 decidably not, 101 undecidable |
| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **145 of 217** decidable, **72** undecidable |
| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 99 decidably elevated, 0 decidably not, 101 undecidable |
| 141 and 132, two independent re-derivations | the card that filed this work | — |

**The differences are not reconciled, and deliberately so.** The old census's
Expand All@@ -167,11 +167,11 @@ would report a smaller number and would not say so.

The fourth row is the one worth flagging to anyone citing it. **The 135 / 77%
figure has no surviving corroboration anywhere in the tree.** This census reads
97 of 215 (45%) as decidably elevated, with 101 more whose elevation is a
99 of 217 (45%) as decidably elevated, with 101 more whose elevation is a
run-time fact — so the claim is neither confirmed nor refuted, and the honest
answer is that a static reading cannot settle it.

⇒ **Cite `9 / 215`, and say what it is**: the sites whose options argument was
⇒ **Cite `9 / 217`, and say what it is**: the sites whose options argument was
READ and holds no tenant context, against a decidably tenancy-enabled object.
That is the control's provable yield surface. ⛔ Do not cite it as "the sites
without tenant context" — **32 further sites** have an options argument this
Expand All@@ -183,28 +183,28 @@ cannot read, and they are neither in nor out.

| what | count |
| :--- | ---: |
| write call sites on the application surface | **215** |
| …whose object name is statically decidable | 143 |
| write call sites on the application surface | **217** |
| …whose object name is statically decidable | 145 |
| …whose object name is chosen at run time | 72 |
| …against an object with tenancy ENABLED | 143 |
| …against an object with tenancy ENABLED | 145 |
| …against an object that declares tenancy off | 0 |
| threading a tenant context | 131 |
| threading a tenant context | 133 |
| PROVABLY carrying none (options read, no context key) | **17** |
| …of those, against a decidably tenancy-enabled object | **9** |
| options argument UNREADABLE — may or may not carry one | 67 |
| …of those, against a decidably tenancy-enabled object | 32 |
| threading a decidably ELEVATED (`isSystem`) context | 97 |
| threading a decidably ELEVATED (`isSystem`) context | 99 |
| threading a context that is decidably NOT elevated | 0 |
| threading a context whose elevation is a run-time fact | 101 |

| how the instrument reached the site | count |
| :--- | ---: |
| receiver carried a readable engine type | 170 |
| receiver carried a readable engine type | 172 |
| receiver erased, placed by the object NAME | 19 |
| receiver erased, placed by an `object: string` PARAMETER | 15 |
| receiver erased, placed by an `UNTYPED_RECEIVERS` row | 11 |

| object name spelled inline | 106 |
| object name spelled inline | 108 |
| object name spelled through a `const` | 37 |
| object name is an `object: string` parameter | 19 |
| object name is some other run-time expression | 53 |
Expand All@@ -224,7 +224,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand Down
16 changes: 8 additions & 8 deletions docs/audits/2026-08-tenant-audit-write-call-sites.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,17 +29,17 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution.

| Measure | Value |
|---|---:|
| Write call sites | 215 |
| Object name statically decidable | 143 |
| Write call sites | 217 |
| Object name statically decidable | 145 |
| Object name chosen at run time | 72 |
| Against a tenancy-enabled object | 143 |
| Against a tenancy-enabled object | 145 |
| Against an object declaring tenancy off | 0 |
| Threading a tenant context | 131 |
| Threading a tenant context | 133 |
| Provably carrying none | 17 |
| …and decidably tenancy-enabled | 9 |
| Options argument unreadable | 67 |
| …and decidably tenancy-enabled | 32 |
| Threading a decidably elevated context | 97 |
| Threading a decidably elevated context | 99 |
| Threading a decidably non-elevated context | 0 |
| Threading a context of undecidable elevation | 101 |

Expand All@@ -52,7 +52,7 @@ holds still. They are required to be HERE and to say WHEN they were true;
their values are not compared. The reasoning, and the measurement behind it,
are in `scripts/check-tenant-audit-census.mjs`.

Measured on 2026-08-31 at `fc8858a24`.
Measured on 2026-09-01 at `d3ebf3b55`.

| corpus scale (not enforced) | count |
| :--- | ---: |
Expand All@@ -66,11 +66,11 @@ Measured on 2026-08-31 at `fc8858a24`.
| file | verb | object | tenancy | tenant context | n |
|---|---|---|---|---|---:|
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `object` | undecidable | context, elevation undecidable | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 13 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_action` | enabled | elevated | 14 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `delete` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_approver` | enabled | elevated | 2 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_request` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 8 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_request` | enabled | elevated | 9 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `insert` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/approval-service.ts` | `update` | `sys_approval_token` | enabled | elevated | 1 |
| `packages/plugins/plugin-approvals/src/backfill-platform-row-organizations.ts` | `update` | `objectPlan.object` | undecidable | context, elevation undecidable | 1 |
Expand Down
Loading
Loading