Skip to content

feat(ai): tier-3 supervised/four-eyes approval split — backend core - #3175

Merged
ToddHebebrand merged 20 commits into
mainfrom
ToddHebebrand/tier3-supervised-four-eyes
Aug 7, 2026
Merged

feat(ai): tier-3 supervised/four-eyes approval split — backend core#3175
ToddHebebrand merged 20 commits into
mainfrom
ToddHebebrand/tier3-supervised-four-eyes

Conversation

@ToddHebebrand

Copy link
Copy Markdown
Collaborator

What

Splits AI Tier-3 approvals into two scopes (spec: docs/superpowers/specs/ai-mcp/2026-08-05-tier3-supervised-four-eyes-split-design.md, in this PR):

  • supervised (default): the AI is the actor, the requesting human is the approver — plain Approve click in chat, gated on the requester's own RBAC for the underlying action. No second human. Routine device work (execute_command, run_script, file/registry/service/process ops, patch install) lands here.
  • four_eyes (explicit list, exhaustiveness contract-tested): a second human holding approvals:decide decides — financial issuance/payment, tenant-shape changes, M365/Google identity mutations, restore/DR, containment release, computer control. Chat approval window raised 5→60 min for these; the durable release worker executes late approvals under a 10-min release lease.

Motivation: for orgs with ≥2 admins and no mobile app (all self-hosters — the app isn't distributable yet), every Tier-3 action fanned out four-eyes with push-only notification and a 5-minute window → guaranteed expiry. Reported by a self-hosted partner 2026-08-05.

Also fixes (found during design review + implementation)

  • Approver resolver counted disabled/invited users as eligible — inflating four-eyes and suppressing the sole-operator fallback (PAM twin filed as PAM: resolveElevationApprovers counts disabled/invited users as eligible approvers #3174).
  • Decide endpoint was non-atomic: approval row committed, then intent transition/outbox could fail silently with HTTP 200 — now one transaction, intent-first lock order (removes a decide/reaper/report-suspicious deadlock class), 500 retryable on fault. report-suspicious got the same treatment.
  • TOCTOU on approved content: four-eyes intents pin an effect digest (script body, quote/invoice revision, org status) revalidated at release — including the inline chat release path — failing with content_changed on drift.
  • Expiry split: approval_expires_at (pending) + release_by lease (approved) — an approval at 59:59 no longer gets reaped before the worker claims it; legacy-row COALESCE fallbacks for rolling upgrades.
  • /pending live authorization: demoted approvers stop seeing request arguments; keyset pagination + GET /pending/count; transport-neutral /api/v1/approvals mount (mobile alias kept, response shape backward-compatible with additive nextCursor).
  • Partner enforcing assurance floors still apply to supervised decides (WebAuthn step-up honored; plain click only when not enforcing).

Reviewer callouts

  • SIEM/audit shape: action_intent.self_approved_sole_operator now fires only for genuine four-eyes sole-operator self-approvals; supervised decides audit approvalMethod: 'supervised_self'. approved flag semantics per [API] Decide: should read-only execute_command types auto-execute under the default per_step approval mode? #3130 unchanged.
  • Behavior change: decide fan-in fault now returns 500 retryable (was 200 with half-applied state); the old integration test asserting that was inverted deliberately.
  • Migration: 2026-08-14-intent-approval-scope-and-deadlines.sql (forward-dated to sort after existing 2026-08-13; idempotent; extends the immutability trigger). All five new columns registered in the export policy.
  • MCP unchanged: both scopes still fail closed (MCP_APPROVAL_REQUIRED).
  • Web approvals inbox + notification events are Plan 2 (chat SSE already carries approvalScope; the in-chat supervised card is actionable today via the existing sole-operator card path).
  • Conservative classifications parked for follow-up: 11 Google helpdesk-ish tools + request_elevation are four_eyes pending explicit demotion decisions.

Testing

Full unit suite (20k+ tests), RLS suite, integration suite (incl. new intentSupervisedFourEyes, approvalsDecideSupervised, inverted atomicity test, reaper legacy-row cases) green locally; db:check-drift clean. Contract tests: classification exhaustiveness (every effective-tier-3 surface explicitly scoped), durable-executable (four-eyes ⊆ headless-executable, input-aware tools included), export policy.

🤖 Generated with Claude Code

Todd Hebebrand and others added 17 commits August 6, 2026 00:06
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… scopes

Adds TIER3_FOUR_EYES_ACTIONS/TOOLS and TIER3_SUPERVISED_ACTIONS/TOOLS plus
resolveApprovalScope() per the 2026-08-05 tier3-supervised-four-eyes design
(§3.1/§3.2): within tier 3, four_eyes requires a second approvals:decide
holder; everything else is supervised (requester self-approves). checkGuardrails
now surfaces approvalScope on both tier-3 return branches.

Exports getAllRegisteredToolNames() from aiTools.ts (core registry + M365/Google
tier maps) so the exhaustiveness contract test can walk every real tool.

s1_threat_action gets a new per-action split in TIER3_ACTIONS (kill/quarantine
supervised, rollback four_eyes) since its action enum is a real discriminator,
unlike s1_isolate_device's boolean isolate flag which stays whole-tool
supervised per spec §3.1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…st columns

Adds approval_scope/classification_version (immutable tier-3 classification
from checkGuardrails), approval_expires_at/release_by (split pending-approval
vs execution-lease deadlines), and effect_digest (four_eyes content-pinning
hash) to action_intents, per the tier3-supervised-four-eyes-split design.
Extends action_intents_immutable_trg to cover the three immutable columns;
release_by/approval_expires_at stay mutable for lifecycle stamping. Registers
the new columns in the export-policy allowlist and updates the schema
contract test's column-list assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
resolveIntentApprovers counted disabled/invited users as eligible
Tier-3 action-intent approvers on both the org-member and partner-axis
candidate queries, inflating the four-eyes approver set and wrongly
suppressing the sole-operator fallback. Join `users` and gate on
status='active' on both queries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t deadlines

Task 4 of the tier3-supervised-four-eyes backend plan. createActionIntent now
reads checkGuardrails' approvalScope, stamps approvalScope/classificationVersion
on the intent, and short-circuits supervised intents to a single requester-owned
approval row (no approvals:decide required, no push). Four-eyes chat deadlines
extend to 60 minutes; supervised keeps the existing 5-minute window. Legacy
expiresAt is dual-written alongside the new approvalExpiresAt column for
rolling-upgrade compat (removal tracked as Plan 3 cleanup).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extract insertSingleApproverRow(userId) helper shared by the supervised
short-circuit and the four_eyes sole-operator branch — both previously
had byte-identical 8-line blocks to insert one approval_requests row
and derive approvalRequestIds/requesterApprovalRequestId/fanOutUserIds.
Behavior unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pending_approval intents now expire on approval_expires_at while approved
intents expire on release_by (falling back to expires_at for legacy rows),
in both the expiry reaper and the release worker's claim CAS. The approve
fan-in stamps release_by = now() + RELEASE_LEASE_MS (10m) on an approval
win, so an intent approved just before approval_expires_at gets a fresh
lease instead of being reaped for a deadline that no longer governs it
(the "59:59 trap").

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tier-3 SUPERVISED intents let the requester self-decide with no WebAuthn
assertion, gated on a live re-check of the underlying tool's RBAC
permission (not approvals:decide) via the same buildAuthContextForIntent +
checkToolPermission pair the release worker uses. A non-requester is
refused (403 not_requester) even if they hold approvals:decide.

The whole decide write — approval-row CAS, ai_tool_executions mirror, and
the action-intents fan-in (intent CAS + release_by + sibling expiry +
intent_approved outbox insert) — now commits as ONE system-scoped
transaction, so a fault anywhere inside rolls everything back and the
caller gets a retryable 500 instead of a half-applied decision (approval
decided with no intent/outbox follow-through). report-suspicious's
intent-rejection block gets the same fail-loud treatment instead of
swallowing the error.

Extends aiAgentSdk.ts's ApprovalMethod audit union with 'supervised_self'
so the postToolUse audit event distinguishes a supervised self-decide from
a four_eyes approval via the approvals surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…, enforcing floor

Fix round 1 on the supervised plain-decide + atomic-decide-transaction work:

1. report-suspicious: fold the approval-row 'reported' flip into the SAME
   transaction as the intent CAS + sibling expiry. Previously the flip
   committed separately BEFORE that transaction opened, so a throw inside it
   left the row 'reported' with the intent permanently stranded
   pending_approval (a retry's pre-fetch saw status!=='pending' and never
   re-entered the fan-in). A rollback now restores 'pending' too.

2. Lock-order inversion: both the decide-write transaction and
   report-suspicious's intent-reject transaction now take a
   `SELECT ... FOR UPDATE` lock on the intent row FIRST, before touching
   approval_requests — matching report-suspicious/intentExpiryReaper's
   existing order. The decide handler previously locked its own approval row
   first and the intent second, the opposite order, which could deadlock
   (Postgres 40P01) against a concurrent report-suspicious/reaper transaction.

3. The ai_tool_executions mirror (system-scoped, no RLS) now carries an
   explicit `exists(...)` tenant/linkage guard in its UPDATE's WHERE clause —
   verifying the execution's ai_sessions row belongs to the same user as the
   approval_requests row being decided — instead of relying solely on the
   (already-trusted, but app-layer-only) executionId FK value.

4. New `approvalsDecideSupervised.integration.test.ts`: drives the real
   approve/deny route against genuine role/permission state (not the mocked
   buildAuthContextForIntent/checkToolPermission the unit suite stands in
   for) — happy path (200, intent approved, outbox written) and a
   revoked-permission path (403, nothing written).

5. Adjudicated requirement: a supervised decide now checks whether the
   partner's authenticator policy is actively enforcing before skipping the
   assurance ladder. When enforcing, the requester goes through the same
   ladder a four_eyes sole-operator self-approve does (WebAuthn L3 satisfies
   it); non-enforcing partners keep the unchanged plain-click path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds effectDigest.ts (SHA-256 over resolved tool-specific target content:
run_script body, quote send revision, invoice/contract state, org status).
createIntent computes and stores it for four_eyes intents only, inside the
creation transaction; the release worker recomputes it immediately before
execution and CASes to failed:content_changed on a mismatch, closing the
TOCTOU gap where an approver signs off on a reference whose target mutates
during the (up to 60-minute) four_eyes approval window.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-neutral mount

GET /pending now joins action_intents and drops rows whose intent is no
longer pending_approval, or whose caller no longer qualifies live
(four_eyes: current approvals:decide + org access; supervised: still the
requester) — a demoted approver stops seeing (and count-ing) a row it can
no longer decide. Adds keyset (createdAt, id) pagination (limit capped at
50, opaque cursor, additive nextCursor field) and a new GET /pending/count
using the identical filter set. Both routes are now also mounted
transport-neutrally at /api/v1/approvals, alongside the existing
/api/v1/mobile/approvals alias, with the same mobileDeviceBlockedMiddleware
so a blocked device can't dodge the check via the new prefix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…le four-eyes contract

- resolveApprovalScope now takes `input` and resolves manage_organizations:
  update_org (four_eyes only when `status` is present, else supervised) and
  s1_isolate_device (four_eyes only on isolate:false containment release,
  else supervised) from argument content instead of a static tool/action
  table. Both are exempted from the contract test's static exactly-one-scope
  invariant via new TIER3_INPUT_AWARE_ACTIONS/TIER3_INPUT_AWARE_TOOLS sets
  and covered by dedicated both-branches tests.
- aiAgentSdk's chat SSE approval_required event for the tier-3 durable-intent
  path now carries approvalScope alongside the existing selfApprovalRequestId,
  so the web approval card can distinguish a supervised self-approve from a
  four_eyes sole-operator self-approve (AiStreamEvent type updated in
  packages/shared). Push dispatch is unchanged: it was already fully owned
  and gated to four_eyes inside intentService.ts's createActionIntent; the
  only push call site in aiAgentSdk.ts is the unrelated Tier-2 legacy
  per_step bridge.
- New durable-executable contract test
  (intentReleaseWorker.durable.contract.test.ts) asserts every
  four_eyes-classified tool (whole-tool or per-action) is releasable by the
  headless worker (isSessionRequiredForRelease, newly exported) rather than
  stuck needing a live chat session — it passes as-is, since Phase 2's
  Google/M365 headless dispatch already covers every four_eyes member.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
fourEyesTools was built only from the static TIER3_FOUR_EYES_TOOLS/
TIER3_FOUR_EYES_ACTIONS tables, so s1_isolate_device (moved to
TIER3_INPUT_AWARE_TOOLS) and manage_organizations (only covered by
accident via create_org) were structurally invisible to the
durable-executable contract even though they can resolve four_eyes at
runtime via resolveApprovalScope's input-aware overrides. Union in
TIER3_INPUT_AWARE_TOOLS and the tool-name projection of
TIER3_INPUT_AWARE_ACTIONS — a conservative superset is correct since
the assertion is about session-requirement, not resolved scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds intentSupervisedFourEyes.integration.test.ts, covering what the
existing decide-path integration suites don't: a four_eyes intent fans
out to both other eligible admins (never the requester), an approve at
t+30min still succeeds and releases inside the new 60-minute four_eyes
window (via direct DB timestamp manipulation, not a mocked clock), and
a disabled second admin correctly engages the four_eyes sole-operator
fallback instead of reclassifying to supervised.

approvalsDecideSupervised.integration.test.ts already covers the
supervised happy-path + revoked-permission; approvalsDecideAtomicity.
integration.test.ts already covers the fan-in fault-injection rollback.

Named in vitest.integration.config.ts's include list for discoverability
(the file already lives under src/__tests__/integration/, which both
that config's glob and vitest.config.ts's wholesale exclude already
cover automatically).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…four-eyes split

execute_command was reclassified `supervised` by an earlier task in this
plan (Task 1, aiGuardrails.ts), which collapses its approval fan-out to
exactly one requester-owned row. intentFanout.integration.test.ts and
intentSelfApproveGuard.integration.test.ts still seeded intents with
execute_command and asserted the pre-split multi-approver/four_eyes
sole-operator behavior, so they were either failing outright (wrong row
count/owner) or passing for the wrong reason (silently routed through the
unrelated supervised decide branch instead of the four_eyes one they exist
to cover). Switches every intent-creation call in both files to
restore_snapshot (four_eyes-classified), the same fix
approvalsDecideAtomicity.integration.test.ts already made for its own
fixture.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Final whole-branch review turned up three IMPORTANT gaps:

1. The inline chat-session release path (aiAgentSdk.ts) won the
   approved->executing CAS and revalidated the requester's authorization,
   but never recomputed the pinned effect digest the way
   jobs/intentReleaseWorker.ts does — a four_eyes reference-approval whose
   target drifted during the approval window could execute stale content
   if it happened to complete inline instead of via the durable worker.
   Mirror the worker's computeEffectDigest recheck and CAS to
   failed:content_changed on mismatch.

2. Supervised approves polluted the four_eyes sole-operator audit signal:
   a supervised intent's sole approval row is always requester-owned, so
   every ordinary supervised approve was audited as
   self_approved_sole_operator (an outcome meant to flag four_eyes L3
   self-approval specifically). Gate soleOperatorApproval /
   details.soleOperator on approvalScope === 'four_eyes' in both
   routes/approvals.ts and services/actionIntents/intentService.ts;
   supervised approves now audit as approved with
   details.approvalMethod: 'supervised_self'.

3. The reaper's pending_approval predicate used bare
   approval_expires_at < now(), which is SQL NULL (never true) for legacy
   writer rows that never got the column backfilled — those rows could
   never be reaped even once their legacy expires_at deadline passed.
   Fall back to COALESCE(approval_expires_at, expires_at), mirroring the
   approved branch's existing construct.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying breeze with  Cloudflare Pages  Cloudflare Pages

Latest commit: 0d02b3d
Status: ✅  Deploy successful!
Preview URL: https://db0c61b0.breeze-9te.pages.dev
Branch Preview URL: https://toddhebebrand-tier3-supervis.breeze-9te.pages.dev

View logs

Todd Hebebrand and others added 2 commits August 6, 2026 09:35
Multi-agent review of the tier-3 supervised/four-eyes split surfaced two CI
blockers, four fail-open defects, and a set of missing test contracts.

CI blockers:
- Reserve the new `approvals` route namespace in BOTH hand-maintained mirrors
  (extension-sdk/manifest.ts, extension-api/legacy.ts). Without it an extension
  could claim the namespace and shadow the approvals API.
- Fix TS2769 in the durable four-eyes contract test under
  noUncheckedIndexedAccess.

Authorization / fail-open:
- Supervised approve no longer discards a presented WebAuthn proof. A forged or
  replayed proof previously succeeded silently at L1 and audited as
  `session_tap`. The sole-operator L3 gate is now conditioned so an optional
  proof can only raise recorded assurance, never block a decide that a plain
  click would have passed.
- report-suspicious returns 409 `already_decided` (with finalStatus) instead of
  a 204 that was indistinguishable from "stopped it" while the flagged action
  still executed. The approval_requests flip is now a CAS on status='pending',
  so a losing race can no longer overwrite a committed approval.
- GET /approvals/:id and the assertion-challenge endpoint now apply the same
  live-authorization filter as /pending. A demoted approver could still read
  full action arguments (script bodies, target emails) from the detail route.

TOCTOU / effect digest:
- Pin effect digests regardless of approval scope. The run_script resolver —
  the module's own motivating example — was unreachable, since pinning was
  gated on four_eyes while run_script is supervised.
- computeEffectDigest returns a three-way EffectDigestOutcome; "resolver
  missing an arg" and "target absent" are now audited instead of silently
  storing NULL and disabling the check.
- run_script pins every field that changes what executes, not just content.
- Add manage_invoices:void resolver and a resolver-coverage contract test with
  an explicit DELIBERATELY_UNPINNED allowlist.
- Share one hasPinnedDigest predicate across both release paths; they
  previously diverged on undefined (worker failed closed, SDK failed open).
- Wrap the worker's recompute so a transient fault reports digest_check_failed
  instead of stranding the intent for 20 minutes as execution_lost.

Contracts / correctness:
- Enumerate multiplexed tools' real action enums: an action in neither tier-3
  table fell through to the whole-tool supervised catch-all rather than the
  documented four_eyes fail-safe.
- Single approvalScope union in @breeze/shared, pinned against the migration's
  CHECK literals.
- requireNotExpired is phase-typed ('approval' | 'release') and throws when the
  phase contradicts the from-status.
- Stamp the release lease unconditionally; backfill release_by for pre-split
  approved rows (2026-08-15 migration). Deliberately no NOT NULL / CHECK — both
  would reject old-instance writes mid rolling upgrade; rationale recorded in
  the migration.
- Extend the action_intents immutability contract test to every deny-listed
  column (including the previously-missed origin_principal_* pair) and assert
  release_by / approval_expires_at stay mutable.
- Re-point createIntentAtomicity at a four_eyes tool; it had silently lost
  multi-approver fan-out coverage.
- Replace unit tests that asserted SQL text or mocked away the behavior in
  their own titles with real-DB coverage, incl. a tight deadline boundary and
  legacy release_by IS NULL release.

Full API unit suite green (20286), shared 1660, extension sdk/api green;
tsc --noEmit -p apps/api/tsconfig.json clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rewritten contract test covered the new approval_scope /
classification_version / effect_digest columns, but its ~28 live-DB cases
never executed anywhere in CI: they sat behind describe.runIf(DATABASE_URL),
the blocking test-api job has no Postgres, and the file was not in the
integration config's include list. The immutability of approval_scope — the
property that stops an intent switching approval classification after
approvers have acted — was asserted nowhere.

Split the suite along the line CI actually draws:
- Static DDL/parse assertions stay in src/db/migration-action-intents.test.ts
  and keep running in the fast, blocking test-api job.
- The 28 behavioral cases move to
  src/__tests__/integration/actionIntentsImmutabilityTrigger.integration.test.ts
  with per-test fixtures so they are truncation-safe. That directory is matched
  wholesale by both configs (integration include, unit exclude), so it needs no
  hand-maintained entry in the list that has gone stale before.
- Shared deny-list derivation extracted to testUtils so the two suites cannot
  drift into vacuity independently.

Verified non-vacuous: removing the approval_scope clause from the trigger in a
throwaway test DB fails the suite twice over — the behavioral UPDATE case and
the live-function-vs-migration drift gate — and restoring returns it to 28/28.

Runs in the Integration Tests job, which is blocking on PRs and main (the
continue-on-error in ci.yml belongs to smoke-test, not integration-test).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ToddHebebrand pushed a commit that referenced this pull request Aug 7, 2026
…vers (#3174) (#3183)

Fixes #3174.

## The defect

`resolveElevationApprovers` (`apps/api/src/services/pamApprovers.ts`)
resolves both candidate sets — direct `organization_users` members, and
`partner_users` members whose `org_access` covers the org — without ever
consulting `users.status`. Memberships are retained when an account is
disabled or is still `invited`, so those accounts keep counting as
eligible approvers.

Both consequences named in the issue follow: the approver set is
inflated with push targets that can never respond, and any logic keyed
on the approver count is skewed by ghosts that look like real approvers.

## The fix

Both candidate queries now `innerJoin(users)` and require `status =
'active'`, which is the same shape `resolveIntentApprovers` uses on the
AI side. Nothing else changed — the wildcard-role handling, the partner
`org_access` logic and the final `mobile_devices` narrowing are
untouched.

No new table and no new column, so there is no cascade or export-policy
registration to add.

## Test evidence

The two new cases assert the join target and the predicate that actually
reach drizzle, **not** the ids the mock was primed to return. That
distinction matters here: the mock resolves its rows regardless of the
`WHERE`, so a test that only checked returned ids would pass just as
happily with the gate deleted.

**Confirmed against the previous behaviour.** Removing *only* the
`eq(users.status, 'active')` predicate from both queries — keeping the
join, so the failure is about the gate and not a broken mock chain:

| | Result |
|---|---|
| Status predicate removed | **2 failed / 4 passed** — exactly the two
new cases |
| Fix in place | **6 passed** |

Full runs on the final tree, Node 22.23.2:

- `tsc --noEmit -p apps/api/tsconfig.json` → **exit 0**, no output
- `apps/api` full unit suite (the `test-api` job) → **1271 files passed
/ 5 skipped, 20144 tests passed / 61 skipped**
- Targeted: `pamApprovers`, `routes/agents/elevationRequests`,
`services/actionIntents` → 13 files / 174 tests passed

## Scope note

This is the PAM half only.
`apps/api/src/services/actionIntents/intentApprovers.ts` is the AI-side
twin and is being fixed on the `tier3-supervised-four-eyes` branch
(#3175); this branch deliberately does not touch it, so the two do not
conflict.

https://claude.ai/code/session_01RUup17Z6KMH9jSkhBBhRJ1
Resolves the vitest.integration.config.ts include-list conflict: both sides
appended entries, so both are kept. Main added the #3191 enrollment-key
expired-filter test; this branch added the three tier-3 supervised/four-eyes
integration suites.
@ToddHebebrand
ToddHebebrand merged commit 1516048 into main Aug 7, 2026
58 checks passed
@ToddHebebrand
ToddHebebrand deleted the ToddHebebrand/tier3-supervised-four-eyes branch August 7, 2026 20:26
ToddHebebrand added a commit that referenced this pull request Aug 8, 2026
Docs staleness sweep for the v0.102.0..v0.104.0 range (tracked in
`scripts/docs-review/last-reviewed.json`).

## Page updates

- **features/ai.mdx** — rewrote the Tier 3 approval workflow for the
supervised/four-eyes split (#3175): supervised actions are approved by
the requesting technician in chat (gated on their own permissions); a
fixed high-stakes list (financial, tenant shape, M365/Google identity,
restores/rollbacks, computer control, containment release) requires a
second approver, now with a 60-minute window and content-change pinning.
Sole-operator and `approvals:decide` sections rescoped to four-eyes;
disabled/invited users noted as ineligible approvers.
- **features/devices.mdx** — WAN IP / LAN IP opt-in device-list columns
(#2996).
- **features/quotes.mdx** — new "Ordering What You Sold" section: the
To-be-ordered procurement breakdown on won quotes, Mark ordered / Mark
received tracking, CSV export, vendor cost snapshot, Pax8 badges
(#3111).
- **features/scripts.mdx** — partner-wide ("All my organizations")
scripts, the Available-to picker, and the full-partner-access
requirement for partner-wide writes (#3262/#3263); fixed the API
section's org-only claim.
- **features/edr-integrations.mdx** — chunked resumable package uploads
with progress (#3113).
- **features/snmp.mdx** — poll due-check runs off attempts; failing
devices back off exponentially (scheduler description + troubleshooting
entry) (#3223).
- **agents/enrollment-keys.mdx** — installer capacity figure and key
status derived from live bootstrap tokens (#2993/#3045/#3196).

Verified as already self-documented in range (no action): Quick Support
(#3153), third-party ring auto-approve (#3150), VSS writer health +
partial status (#3005/#3030), EVENT_LOOP_MONITOR_*/DB_POOL_HEALTH_* env
vars (#3024/#3224), strict env validation (#2979), custom
alert-condition retirement (#2995), enrollment idempotency (#3063),
extensions install scoping (#3032).

## Housekeeping

- `scripts/docs-review/mapping.json` — added `quickSupport*`,
`eventLoop*`, `dbPoolHealth*` patterns; `approvals.ts` now also maps to
features/ai.mdx; `partnerWideAccess.ts` now also maps to
features/scripts.mdx.
- `packages/shared/src/utils/docsMapping.ts` — `/remote/quick-support` →
Quick Support section anchor (+ test). `@breeze/shared` tests green
(1661 passed).
- `apps/api/src/data/docsIndex.json` regenerated (147 docs indexed).
- `docs/release-notes/next-release-draft.md` cleared; last release set
to v0.104.0 (2026-08-08).
- `scripts/docs-review/last-reviewed.json` bumped to v0.104.0.

Docs build verified: `astro build` — 150 pages, no errors.

**Follow-up (not in this PR):** `docsMapping.ts` maps `/ai-risk` →
`/features/user-risk/`, but the AI Risk Engine (approval history, tiers)
is documented in `features/ai.mdx` — one of the two should be
reconciled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Todd Hebebrand <todd@lanternops.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant