fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, '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

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, '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

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, '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

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, '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

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, '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

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, '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

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude
, '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

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775) - #14571

Merged
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only
Sep 2, 2026
Merged

fix(approvals): the #3424 override reaches a pending request only — refuse override-recall of a returned request (#12775)#14571
os-sales merged 5 commits into
mainfrom
claude/issue-12775-override-recall-pending-only

Conversation

@os-sales

Copy link
Copy Markdown
Collaborator

Fixes#12775

Ruling of record (verbatim, untranslated)

Maintainer, 2026-09-02, via the director seat (12775#issuecomment-5504841854), replying to the decision batch whose item for this card recommended 「(ii),附条件:实施 PR 的 changeset 把这条收窄写成显式行为变更;回退 (i)」:

「14324 等我发版,其他同意」

Read as (ii): the audit reading is unobtainable and counts as zero; the 2026-08-29 ruling stands — B: re-scope the status-blind override short-circuit in recall to pending so the gate, the prose and the viewer flag agree at one point; A remains the fallback; the changeset declares the narrowing as an explicit behaviour change so one revert restores it; one pin on the narrowing (returned + override actor ⇒ refused) with the reverse check; #12716 (landed as PR #12791) is unaffected. The card's own facet block (the shape agreed on 2026-08-29):

「推荐:B,附硬前置条件 —— 落地前先从审计留痕读出「是否真有 override actor 在 returned 上撤回过」。读数为零才动手;非零则当场改判 A,⛔ 不许带着非零读数硬做。回退:A。」

The hard precondition is discharged by ruling (ii): no audit reading was attempted and no audit instrument was built.

What changed

ApprovalService.recall (packages/plugins/plugin-approvals/src/approval-service.ts): the #3424 override short-circuit is now conditioned on raw.status === 'pending' — spelled exactly as attachViewers computes viewer.can_override — so on returned an override actor is judged as any other non-submitter and gets the existing refusal (message prefix FORBIDDEN: + the operation catalog's approval_recall_not_submitter sentence; over REST, handleApprovalError maps that prefix to 403 + code FORBIDDEN). No new error code, no new envelope shape, no new export. The comment block above the gate (the old #3424 / #11993 text, whose sentence "the GATE is untouched" is no longer true) is rewritten to state the pending-only scope and this card; the recall doc block gains the same statement. The developer half of the refusal (the logger.warn line, level unchanged) now also names the status it refused on — its text carries no tracker id (check:doc-authoring).

Design note — system context.isOverrideActor's first arm is context.isSystem, so the same expression also scopes a system caller's override to pending. A machine caller keeps a path on returned by naming the submitter as actorId (resolveActor returns a system caller's explicit actor), which is the ADR-0044 semantics. No in-repo caller invokes recall under a system context (packages/rest/src/rest-server.ts is the only call site, with the request's own context); the #13568 record-delete path cancels, it does not recall. Flagged in the report's open questions as a note, not a blocker — spelling the gate as the viewer flag is what the ruling asked for.

Premise verification (on origin/mainfed4fa409, before any edit)

  • P1 holdsrecall admitted pending and returned, and the gate if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && …) passed an override actor regardless of raw.status (:2904).
  • P2 holdsattachViewers computed can_override: row.status === 'pending' && this.isOverrideActor(...) (:5195); the isOverrideActor doc block said PENDING (:938).
  • P3 holdsapproval_recall's visible (PR fix(plugin-approvals): show approval_recall for the #3424 admin override (#12716) #12791) ORs record.viewer.can_override, pending-only through the flag; no UI path offered override-recall on returned.
  • P4 holds — no packages/spec change needed.
  • P5 holds — no pin asserted an override actor can recall a returned request. Every existing admin-recall pin (approval-service.test.ts, admin-exemption-retired.test.ts, recall-refusal-user-copy.test.ts) opens a pending request; approval-revise.test.ts:357 already asserted the non-submitter refusal on returned for a non-admin. Nothing to reverse or register.

Hypotheses

  • H1 holds — one condition change at the gate; the override caller on returned falls into the existing refusal branch with its existing catalog sentence, developer message and wire prefix.
  • H2 partially refuted — the service branch throws a plain Error whose message starts with FORBIDDEN:; the ADR-0112 envelope (code: 'FORBIDDEN', HTTP 403) is minted at the REST seam by handleApprovalError (rest-server.ts:11477, regex ^FORBIDDEN), the same way every approvals refusal reaches the wire. The pins assert the prefix the mapping keys on (the service-seam half of the contract) and the catalog sentence; a REST-level live-emission pin for that row is outside this card's file surface and is recorded as an out-of-scope finding (see below). No code minted, nothing under packages/spec touched.
  • H3 holdsreturned → unlocked and the submitter's revise-window recall pins in approval-revise.test.ts stay green (24/24 file, 662/662 package).
  • H4 holds — approve / reject / reassign are pending-only at their endpoints (decideNode, loadPendingRow); all four override levers are now pending-only and the isOverrideActor doc block is true without edits.

Pins (packages/plugins/plugin-approvals/src/approval-revise.test.ts, new describe block driving the REAL automation engine and the REAL record-lock hook)

  1. The narrowing — platform admin AND tenant admin (ADR-0095 posture) on a genuinely returned row: viewer.can_override is false, recall rejects with an Error whose message matches ^FORBIDDEN: and equals FORBIDDEN: + BUILTIN_OPERATION_MESSAGES.en.approval_recall_not_submitter; afterwards the status is still returned, no recall action row exists, the record is still unlocked (the refusal touched no lock), the run status is unchanged and not cancelled, and the submitter can still resubmit (round 2 opens).
  2. Reverse check — the same platform admin on the same request while pending: can_override is true, the record is locked, recall lands (recalled, resumed: true, recall action row, lock released).
  3. Reverse check, tenant admin — admitted on pending too (the narrowing is about status, not posture).
  4. Submitter's ADR-0044 recall on returned — lands, resumed: false, run cancelled.
  5. Control — a non-submitter without the override on returned is refused exactly as before, and the override actor's refusal is byte-identical to it (no new refusal shape).

Changeset

.changeset/approval-override-recall-pending-only.md@objectstack/plugin-approvals: patch. Level reason: no exported symbol is added, removed or changed (measured: git diff -U0 fed4fa409...HEAD | grep export matches only the export class ApprovalService hunk-context lines). The body states the narrowing as an explicit behaviour change in plain words (override-recall of a returned request is now refused; only pending requests are override-recallable; the submitter's revise-window recall is unchanged; why), per the ruling's condition. No major / breaking declaration, so no ADR-0087 marker is owed (check:adr-0087-registration green).

Clause-② declaration (from the ACTUAL diff)

Yes — accept-set narrowing on a live endpoint (POST /api/v1/approvals/requests/:id/recall: an override actor on a returned request was admitted, is now refused with 403 FORBIDDEN). Export surface: unchanged (git diff -U0 fed4fa409...HEAD | grep export → hunk-context lines only). needs:contract-review hangs on this PR and on #12775.

Ablation (on the committed tree, HEAD 971c8cabf)

Mutation: const overrideAdmits = raw.status === 'pending'const overrideAdmits = true /* ABLATION-12775: status-blind */ (line-neutral). On disk: injected=1 removed_needle_remaining=0. Run: Tests 2 failed | 22 passed (24) — pins 1 (platform admin: expected null to be an instance of Error) and 5 (Cannot read properties of null (reading 'message') on the admin refusal) red, pins 2–4 green — direction: red, as predicted. Restore: git checkout HEAD -- ABSOLUTE_PATH inside a trap … EXIT INT TERM; proven by git hash-object equal to the HEAD blob (fed51d81…), git diff HEAD empty, marker count 0. No dist/ is involved (the suite imports ./approval-service.js from source), so ablation-dist-preflight does not apply.

Verification on the final head 9da369ffe (after merging origin/main310dd01a0; merge-tree clean against 7286dd58e)

All exits captured before any pipe; verdict lines quoted from the tools.

  • pnpm --filter @objectstack/plugin-approvals exec vitest run --maxWorkers=2Test Files 36 passed (36) / Tests 662 passed (662)
  • pnpm --filter @objectstack/plugin-approvals typecheckcheck:test-typecheck: OK — @objectstack/plugin-approvals's test layer compiles … 8 file(s) / 324 error(s) / 27 pinned signature(s) (test layer unchanged at 324; tsc --noEmit covers src/** minus tests, the test layer is covered by tsconfig.test.json via check:test-typecheck)
  • pnpm check:system-context-censusOK — 109 elevation read sites in 20 packages across 45 files, all anchored; 145 anchors resolve, 27 declared non-read.
  • pnpm check:doc-authoringsibling-package prose ids hold the baseline — 831 pinned site(s) across 231 file(s) … no growth, no burn-down unrecorded.
  • pnpm check:engine-double-contractOK — 753 pinned, 134 in the DEBT ledger, 3 exempt.
  • pnpm check:objectql-double-limitbaseline key set verified against 310dd01: no files added.
  • pnpm check:nul-bytesOK (scanned 7949 text file(s) … no raw ASCII control bytes).

Derived gate family (node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, from the merge base; 61 commands) run in five lock-serialised batches on 971c8cabf/9f08922f6 (the doc-authoring fix landed as 9f08922f6; the merge and census regeneration after it touched no gate input other than the census page, which is re-verified above):

  • Green (55): adr-0087-registration, changeset-no-major, ci-filter-parity, comment-mask-adoption, cross-package-test-inputs (both spellings), doc-frontmatter, doc-route-spelling, docs-section-name, empty-changeset, keyed-text-bounds, plugin-teardown-shape, section-landing-index, shard-attestation, system-context-census, tenant-audit-census, undeclared-dep-imports, docs-audit affected-docs + drift-comment, pm half-states (both spellings), release-rehearsal-clone self-test, lint doc-formula-expressions + doc-security-posture, spec docs / empty-state / liveness / skill-examples (after building the @objectstack/client-react closure — first run was PREREQUISITE NOT MET) / strictness-ledger / variant-docs / yaml-examples, changeset-gate-self-tests, corpus-claim-drift, doc-anchors, doc-authoring (red on the first run: a tracker id in the new log string — fixed in 9f08922f6), docs-audit-scope, docs-redirects, docs-single-h1, engine-double-contract, i18n-stale-fill, logger-receiver-detach, merge-driver, objectql-double-limit, objectui-changeset, page-declaration-shape, published-files, published-readme-links, query-options-erasure, react-page-adapter-contract, role-word, skill-identifier-liveness, slot-lookup, test-source-alias, type-check-coverage, type-source-resolution, vendor-version-stamps, where-matcher, nul-bytes.
  • NOT MEASURED (4, prerequisite unmet in this worktree, none reads this diff's files as inputs; CI runs them with the prerequisite): check-test-completeness (exit 3: PREREQUISITE NOT MET — this gate grades a saved turbo run test log), check:dual-build-cjs-loads (exit 3: PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/), check:i18n (exit 1: PREREQUISITE NOT MET — the workspace CLI is not built), check:type-check-debt (exit 3: PREREQUISITE NOT MET — --re-measure cannot run: 20 workspace dependenc(ies) … have no built type entry point).

Adjacent mechanical fix: content/docs/permissions/system-context.mdx row 42 re-anchored by the gate's own --fix (pnpm gen:system-context-census) — first for this branch's +20-line hunk (971c8cabf), then again on the merged tree after origin/main brought #14542's hunk in the same file (9da369ffe, the os-regen deferral discharged as its own commit per scripts/pm/os-regen-merge.sh). No row content changed.

Out of scope

Generated by Claude Code

🤖 Generated with Claude Code

https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8


Generated by Claude Code

…efuse override-recall of a returned request
`ApprovalService.recall`'s override short-circuit sat above ADR-0044's
widened state check with no status test of its own, so a platform or
tenant admin could recall a `returned` request — a reach no UI ever
offered (`viewer.can_override` is ANDed with `status === 'pending'` where
it is computed) and no pin ever held, while the `isOverrideActor` doc
block and the viewer flag both said pending-only.
Re-scope the short-circuit to `pending`, spelled exactly as the viewer
flag, so the gate, the doc block and the flag agree at one point. On
`returned` an override actor is judged as any other non-submitter and
receives the existing refusal (`FORBIDDEN:` + the catalog sentence); the
developer log line now names the status it refused on. The submitter's
revise-window recall and the override's pending-request rescue are
untouched.
Pins in approval-revise.test.ts: the narrowing (both override postures,
nothing moves — status, actions, lock, run), the reverse checks on
`pending`, the submitter's ADR-0044 recall, and a control that the
refusal shape is unchanged. Changeset declares the narrowing as an
explicit behaviour change.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…ll gate edit
Pure line rot: the recall-gate hunk in approval-service.ts moved the six
`context.isSystem` read sites below it by +20 lines. Rewritten by the
gate's own `--fix` (`pnpm gen:system-context-census`); no row content
changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…me log string
`check:doc-authoring` (maintainer ruling 2026-08-12) refuses a new
internal issue id inside sibling-package string prose; the anchor lives
in the adjacent comment, where the reader who can resolve it reads it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
… tree
Regeneration commit after merging origin/main (the merge driver deferred
content/docs/permissions/system-context.mdx). Both sides had re-anchored
row 42 for approval-service.ts line shifts — this branch's recall-gate
hunk and #14542's JSDoc hunk — so the anchors are re-derived from the
merged code by the gate's own --fix; no row content changed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/plugin-approvals, touching 1 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx(via ApprovalService (symbol, a top-level class))
What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1adpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 5b97d874264945f7494d12240ee5eb67c744ac46 — the merge of head 9da369ffe1d114b4d1684977fe4ca579336365f7 into base 7286dd58e806ed321cbdfc23a1c455db8f80b1ad, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 5b97d874264945f7494d12240ee5eb67c744ac46 && git checkout 5b97d874264945f7494d12240ee5eb67c744ac46
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7286dd58e806ed321cbdfc23a1c455db8f80b1ad 9da369ffe1d114b4d1684977fe4ca579336365f7 && git checkout -B drift-repro 7286dd58e806ed321cbdfc23a1c455db8f80b1ad && git merge --no-ff 9da369ffe1d114b4d1684977fe4ca579336365f7
node scripts/docs-audit/affected-docs.mjs --json 7286dd58e806ed321cbdfc23a1c455db8f80b1ad

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 7286dd58e806ed321cbdfc23a1c455db8f80b1ad → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-salesClaude

Copy link
Copy Markdown
CollaboratorAuthor

Landing provenance — ready + auto-merge at head 9da369ffe


Generated by Claude Code

@os-sales
os-sales added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 1007134Sep 2, 2026
43 checks passed
@os-sales
os-sales deleted the claude/issue-12775-override-recall-pending-only branch September 2, 2026 13:28
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

2 participants

@os-sales@claude