Email lands the human ON the decision — approval deep links + honest landing states#190
Conversation
Captures Eric's 2026-07-11 direction — Apollo-grade connector enable, org scan for shadow AI / seats, autofill seat-plan recommendations, manual path over the same roster — as a phased build spec, verified against shipped primitives (SEATS-1, provision ladder, subtree caps, PACK-1, KILL-1, the promoted hosted-remote-MCP item). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
…rding spec Eric's 2026-07-11 strategy addition: after connect + scan, the org gets monitoring + recommendations with zero governance assigned. Track C adds Policy.enforcementMode (enforce|observe, shadow decisions logged, nothing blocked) and a monitoring digest whose upgrade recommendations replay the org's own observed contexts via the shipped simulation engine. Migration flips pool-by-pool from observe to enforce; readiness ladder becomes the migration UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
…g spec Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
… on TS2554
sanctionTool<T> returns T, so a zero-param inner execute types the wrapped
execute zero-param too; calling it with {} failed tsc. Every main CI run
since #178 was failing on this line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
…lease npm still serves 0.4.0; the announced 0.5.0 was never published (and server.json had stayed at 0.4.0, so the MCP registry would have been mispinned even if it had been). Jump straight to 0.6.0 per Eric: bump package.json, server.json (both fields), the serverInfo version, rebuild the committed bundle, and update the changelog's two version claims so the public record names the version that will actually exist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Eric's 2026-07-12 direction: fully-loaded mock client accounts he can click through and demo — data passed A→B through the real engine, not fixtures. Three personas mapped to the three buyer stories; driver = seed/pulse/history over pure REST; admin oversight via the shipped subtree views; /login-by-mgmt-key as the per-company demo door. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
…feed Outside a tenant transaction the joined CredentialVault row is hidden by RLS and Prisma returns null for the required relation — a child pool's injection crashed the org owner's Audit page. Found live by the demo- company driver's first full run. Regression test pins it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
PR1 of docs/plans/demo-companies.md. REST-only (the customer's own surface): seed builds HQ → company → pools → seats → vault with per- wallet plus-addressed owner emails and wide HQ guardrails (ancestor policies fold into every decision); pulse drives a self-verifying day-in-the-life — token burn to ~87% of Engineering's pooled cap, a seat denied over its own line, the spend ladder end to end, an owner approval redeemed through the grant loop, exec→inject — and leaves two escalations pending so every demo opens with something to approve. pulse --watch polls the staged escalations and completes them live as the owner clicks approve. Keys land in a gitignored key store. Verified end-to-end against local Postgres: all expectations green, audit export verifies (signed, chain-valid), dashboard render-checked via headless Chromium. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
…ision-vyn7iy # Conflicts: # docs/BACKLOG.md # sdk/src/adapters.test.ts
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
history <persona> --days N drives each backdated day through the real engine, then shifts timestamps with a quarantined direct-DB pass (scripts/demo/backdate.ts) that also resets WalletBudgetCounter so enforcement state stays coherent; requires an explicit DATABASE_URL so it can never silently touch an API-only target. Coastline Digital stages the agency story: channels as pools with attribution tags, bookings as outcomes, one channel throttled live by its cost-per-outcome ceiling (min_outcomes cold-start guard holds it silent until the 3rd booking lands), one channel frozen on stage (WALLET_FROZEN). Harbor & Wren LLP stages the regulated story: an already-expired contractor seat failing closed 401, clearance-3 vault injection, tight ladders, a $450 retainer + docusign.send left pending. HQ keys move to a shared store so all personas nest under one root. Verified against a fresh local stack: 3 seeds + 3 backdated months + 3 pulses with zero expectation mismatches; sequential simulation over the real month flips 4/10 decisions under a $50 cap; the audit chain verifies across 32 decisions including backdated ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
docs/DEMO-RUNBOOK.md: bootstrap commands, the three client click-paths with the wow moments marked (live approve→grant→complete, the CPO throttle, the fail-closed contractor, the verifiable evidence export), the HQ admin tour, and the caveats learned by running it (freeze denials don't persist as rows; open on Pools, not Overview). .github/workflows/demo-pulse.yml: daily cron re-pulses all three companies against prod from the DEMO_KEYS_JSON secret — dashboards stay warm and, because every pulse asserts the engine's decisions, a red run is a daily production smoke alarm. Outcome dedupe keys now carry a date suffix so daily re-pulses record new bookings instead of deduping into day one (validated with a same-day re-pulse). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Production has no DATABASE_URL access from the driver, so the CPO ceiling's cold-start guard (min_outcomes) kept Bluebird silent. prime stages it through the API alone: today's approved spends supply the ratio's numerator and occurred_at-backdated bookings satisfy the outcome floor. Verified live: post-prime, Bluebird's $12 charge escalates COST_PER_OUTCOME_CEILING on getsanction.com. Also widens the key-store gitignore to the .local-keys/ stash directory (the previous tip briefly committed local-container-only dev keys — worthless outside this sandbox, and rewritten out of the branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Applied: checkout persist-credentials:false + contents:read permissions (prod keys sit next to executed code); tsx pinned in the lockfile and run via npm ci + npx --no-install (no registry fetch-and-execute); workflow concurrency group (no overlapping pulses); prime documented in the CLI header/usage; pool creation reuses createWallet; shared isWeekday helper across personas; runbook states the per-target order (seed → history|prime → pulse). The real find: Bluebird's CPO arming decays — a throttled channel approves nothing, so its windowed spend only ages out and the throttle disarms in ~10 days (seeded bookings left the 30-day window even sooner). Tightened prime bookings to 5/3/1 days ago (~25-day hold), fixed the ratio math for fresh targets (4×$48=$192 → $64 > $60), date-scoped the prime dedupe keys so re-priming mints fresh rows, and documented the red-run→re-prime maintenance beat. The decay itself is honest engine behavior, so it's operated around, not patched over. Skipped with reason: history() resumability (fresh-target staging tool; documented recovery is wipe/reseed — the mismatch guard already stops before any backdate) and backdate index additions (AuthorizationRequest, TokenLog, PendingApproval, Grant already carry (agentId, createdAt) indexes; OutcomeEvent has (walletId, kind, occurredAt) — demo-scale). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Policy.enforcementMode: enforce | observe. Observe runs the identical rule fold and persists the truthful would-be decision — marked observed in detailsJson, evidence-replayable (verified: replay.matches on a live observed denial) — but blocks nothing: the response reads approved with the real decision in would_be (existing SDK/MCP clients proceed untouched), escalations log without paging anyone, and no enforcement state is written: no cascade-counter or exec-token debits, so an observed pool can never trip a shared cap and cause real denials for an enforcing sibling. Freeze (KILL-1) and no-policy still enforce — the kill switch is an owner action, and observe is a property of a policy. Slice 1 covers the wedge routes (/authorize, /authorize/tool); the provision/capability routes, gateway token budgets, and an observe-everything pack ride the monitoring-digest slice (C2, docs/plans/connector-onboarding.md Track C). Verified live on the local stack: flip a pool to observe → a blocked spend returns authorized with would_be.CATEGORY_BLOCKED → flip back → it denies again. Migration between modes is a one-field policy PATCH, pool by pool. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
CodeRabbit caught a real one: observe mode skips the cascade counter reserve (correct — zero enforcement writes), but that reserve was the only subtree-cap check, so an observed row could claim would-be-approved when enforcement would deny on a parent's cap. The would_be must be truthful: observe now runs the read-only cascadeDailyWouldExceed check — the same answer FUND-1's simulate path gives — and persists the would-be SUBTREE_CAP_EXCEEDED denial, still writing no counters. Test pins it. Also consolidates isObserved into lib/decisions.ts (shared by both routes; provision/capability join in C2) and gives the tool route the same httpFor helper as spend. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
The escalation email's Review button was a bare link to the approvals inbox: clicked without the right session it silently fell back to the public demo view — an empty inbox that reads as 'already resolved' while the real decision waits in another wallet. The human dropped out of the loop at the exact moment we asked them in. Now: every escalation notification (email, webhook payload, Slack button, AARP task link) deep-links its own decision via approveUrlFor(requestId) → /dashboard/approvals?review=<id>. A ?review click with no session redirects to /login?next=… (validated local-path redirect) and returns to the decision after sign-in. The approvals page resolves the deep link to one of four honest states: waiting on you (banner + highlighted, auto-scrolled card with why-it-paused and what-approving-mints), already decided (status, when, grant state, note), waiting in a descendant pool (named, with the read-only rule explained), or not visible from this wallet (sign-in link that returns here). The demo fallback can never answer a decision click again. Verified live end-to-end in a real browser: logged-out email click → login with destination preserved → highlighted decision card → approve via API → revisit shows the truthful terminal state. Dev-mode email output carries the deep link. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (11)
🚧 Files skipped from review as they are similar to previous changes (9)
📝 WalkthroughWalkthroughApproval notifications now include request-specific review links. The approvals dashboard resolves review deep links, focuses matching decisions, and preserves unauthenticated review targets through a validated login redirect. ChangesApproval deep-link flow
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Notification
participant ApprovalsPage
participant LoginPage
participant loginAction
participant ApprovalQueue
Notification->>ApprovalsPage: open ?review=requestId
ApprovalsPage->>LoginPage: redirect with next review URL when unauthenticated
LoginPage->>loginAction: submit credentials and next
loginAction-->>ApprovalsPage: redirect to validated review URL
ApprovalsPage->>ApprovalQueue: pass focusId
ApprovalQueue->>ApprovalQueue: scroll to and highlight matching decision
Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
CI caught what my local gate hid: eleven route-test files mock
@/lib/webhooks with a factory returning only {deliverEvent, APPROVE_URL},
so routes now calling approveUrlFor threw 'no export defined on mock'.
The factories now provide a test-shaped approveUrlFor mirroring the real
deep-link format. (The local miss was a piped gate swallowing the exit
code — verified with pipefail this time: 870 passing.)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@app/api/access/v1/access-request/route.ts`:
- Line 21: Update the vi.mock("`@/lib/webhooks`", ...) factories in the
access-request and sibling authorize, capability, and provision test suites to
expose approveUrlFor alongside their existing mocked exports. Preserve the
current deliverEvent and APPROVE_URL mocks, either by spreading the real module
or explicitly adding the approveUrlFor mock so success/replay route tests can
call it.
In `@app/dashboard/approvals/page.tsx`:
- Around line 140-179: Update the descendant branch in the focus-resolution
logic to inspect row.status before assigning kind "descendant". Preserve the
pool/title details only for approvals still pending; route already-resolved
descendant rows through the resolved outcome with their status and resolution
metadata, so stale links do not report them as waiting.
In `@app/login/actions.ts`:
- Around line 15-21: Update safeNext to reject local-looking paths containing a
backslash, including values such as `/\evil.com`, before returning them for
redirect. Preserve acceptance of valid single-slash local absolute paths and
fall back to /dashboard for protocol-relative or backslash-prefixed redirect
targets.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c6e26315-975f-4234-9869-20dba7a9e85b
📒 Files selected for processing (12)
app/api/access/v1/access-request/route.tsapp/api/v1/authorize/capability/route.tsapp/api/v1/authorize/provision/route.tsapp/api/v1/authorize/route.tsapp/api/v1/authorize/tool/route.tsapp/dashboard/approvals/page.tsxapp/login/actions.tsapp/login/page.tsxcomponents/approval-queue.tsxcomponents/login-form.tsxlib/webhooks.tstests/webhooks.test.ts
… descendant state safeNext now rejects any next carrying a backslash (raw or %5C-encoded): browsers normalize \ to /, so '/\evil.com' becomes the off-site '//evil.com' protocol-relative redirect. And a deep link to an already-settled descendant approval now says 'was already decided in <pool>' instead of claiming it's still waiting — stale escalation emails get the same honesty as everything else on the landing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
There was a problem hiding this comment.
Actionable comments posted: 11
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@tests/approvals-resolution.test.ts`:
- Line 22: Update the mocked approveUrlFor implementation in the webhooks mock
to encode the provided id with encodeURIComponent before interpolating it into
the review query parameter. Preserve the existing URL and behavior when no id is
supplied, matching the production contract in approveUrlFor.
In `@tests/authorize.route.test.ts`:
- Line 25: Update the mocked approveUrlFor implementation in the `@/lib/webhooks`
mock to wrap the review id with encodeURIComponent before interpolating it into
the query string, matching the production contract while preserving the existing
behavior when no id is provided.
In `@tests/authzen-aarp.route.test.ts`:
- Line 30: Update the mocked approveUrlFor function in the webhook mock to apply
encodeURIComponent to the optional id before embedding it in the review query
parameter, while preserving the existing URL format and behavior when no id is
provided.
In `@tests/authzen-hardening.test.ts`:
- Line 29: Update the mocked approveUrlFor helper in the vi.mock definition to
wrap the interpolated id with encodeURIComponent, matching the production
URL-encoding behavior while preserving the existing optional review query
parameter format.
In `@tests/capability.route.test.ts`:
- Line 26: Update the mocked approveUrlFor function in the `@/lib/webhooks` mock
to apply encodeURIComponent to the optional id before appending it as the review
query parameter, matching the production URL encoding behavior while preserving
the existing URL when no id is provided.
In `@tests/dataplane.route.test.ts`:
- Line 29: Update the approveUrlFor mock in the `@/lib/webhooks` vi.mock to apply
encodeURIComponent to the optional review ID before interpolating it into the
URL, matching the production helper’s contract while preserving the existing
behavior when no ID is provided.
In `@tests/digests.route.test.ts`:
- Line 18: Update the mocked approveUrlFor implementation in the webhooks mock
to apply encodeURIComponent to the optional id before appending it as the review
query value, while preserving the existing URL and no-id behavior.
In `@tests/observe.route.test.ts`:
- Line 27: Update the mocked approveUrlFor implementation in the `@/lib/webhooks`
mock to encode the optional id with encodeURIComponent before appending it as
the review query parameter, matching the production implementation while
preserving the existing URL format.
In `@tests/provision.route.test.ts`:
- Line 27: Update the mocked approveUrlFor function in the `@/lib/webhooks` mock
to apply encodeURIComponent to the optional id before appending it as the review
query parameter, matching the production implementation while preserving the URL
without a query parameter when id is absent.
In `@tests/redteam.test.ts`:
- Line 48: Update the mocked approveUrlFor function in the `@/lib/webhooks` mock
to apply encodeURIComponent to the optional request ID before constructing the
review query parameter, matching production URL encoding while preserving the
existing URL when no ID is provided.
In `@tests/richDenials.route.test.ts`:
- Line 28: Update the mocked approveUrlFor helper in the `@/lib/webhooks` test
double to wrap the request id with encodeURIComponent when constructing the
review query value, while preserving the existing URL format and behavior when
no id is provided.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: c8e23c5e-e235-4658-a38d-2efd225d72bd
📒 Files selected for processing (13)
app/dashboard/approvals/page.tsxapp/login/actions.tstests/approvals-resolution.test.tstests/authorize.route.test.tstests/authzen-aarp.route.test.tstests/authzen-hardening.test.tstests/capability.route.test.tstests/dataplane.route.test.tstests/digests.route.test.tstests/observe.route.test.tstests/provision.route.test.tstests/redteam.test.tstests/richDenials.route.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
- app/login/actions.ts
- app/dashboard/approvals/page.tsx
CodeRabbit round 3: the test doubles interpolated review ids raw while the real helper encodeURIComponents them — reserved-character ids could pass in tests and misbehave in production links. All eleven mocks now mirror the contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
…#193) Bump 0.6.0 → 0.7.0 and drain the truth surfaces (truthsync folded into the cut). The pack since v0.6.0: pay-per-crawl sanctionedFetch (#183), @sanction/sdk publish-ready (#169), Sanction Local install package (#168, #184), observe mode (#187) + observe digest & subtree rollup (#192), email-lands-on-the-decision (#190). Changelog gaps filled (verified against code, not the draft): - v0.7.0 release header (theme: adoption — watch-first, install, drop-in). - Observe mode entry (OBS-1 + OBS-2): the do-nothing on-ramp — real engine records the would-be outcome, blocks nothing, flip-to-enforce per pool with a revisioned timestamp; org roots read the whole subtree. - Email-on-decision entry (#190): escalation notices deep-link to the exact pending request (approveUrlFor), not the generic inbox. Roadmap rotated: Local install → shipped phrasing; observe-mode adoption added to Now as shipped; arc comment updated. (Demo companies, consulting page, and the skills work are infra/site/tooling — no product-changelog entry, by design.) Diff is package.json + changelog + roadmap only. Isolated branch — does not touch the concurrent MCP-demo work. Claude-Session: https://claude.ai/code/session_01UEw1RdHk5bmphKxr6N5mJY Co-authored-by: Claude <noreply@anthropic.com>
The bug (found live by Eric)
Clicking Review & decide in an escalation email landed on the approvals inbox with no context: without the right session it silently fell back to the public demo view — an empty inbox that reads as "already resolved" while the real decision waits in another wallet. The human dropped out of the loop at exactly the moment the product asked them into it.
The fix
approveUrlFor(requestId)→/dashboard/approvals?review=<id>, threaded through all five escalation fan-out sites (spend, tool, provision, capability, AARP access-request), the email template, webhook payloads, the Slack "Review in Sanction" button, and the AARP tasklinks.review./loginlearnsnext— validated local-path redirect (must start with/, never//), threaded through the page and key form. Sign in, land back on the decision.?reviewclick to one of four honest states:?reviewwith no session always routes through login and back.Verification
Approve or reject: …?review=<id>).npm run checkgreen (852 tests, incl. newapproveUrlForcases with URL-encoding of hostile ids).🤖 Generated with Claude Code
https://claude.ai/code/session_01KbjvLUvHRKVDKbYgZEHWMQ
Generated by Claude Code
Summary by CodeRabbit
nextparameter to prevent unsafe navigation.