Live walk defect: the builder generated alternatives questions ("Is Jane's
account a Microsoft account or a local account?") while the UI could only
offer Yes/No. Root cause: SYSTEM_PROMPT mandated a label-less
'<yes/no question>' shape with no way to express the two answers.
- SYSTEM_PROMPT: question nodes must carry yes_label/no_label — the literal
button texts; alternatives questions must use the alternatives as labels.
- validate_node: labels hard-floor-scanned, must be distinct non-empty strings.
- _ensure_labels: server defaults missing labels to Yes/No.
- advance_ai_build: records answer_label (and both labels) in walked_path,
derived from the server-held pending_node — never client-supplied.
- _build_context: LLM context shows the chosen label, not a bare yes/no
(a raw "-> yes" on an alternatives question degrades the next generation).
- normalize_walked_path: captured flywheel trees keep question labels.
- Frontend: buttons render yes_label/no_label; walk transcript and
L1EscalationsSection render answer_label.
Phase 2A backend set: 137 passed / 0 failed / 8 deselected. tsc, eslint,
vite build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Server-assigns a uuid4 id to every AI-generated node (Finding 1 showstopper:
nodes had no id but the advance protocol keys on node_id, so ai_build walks
never advanced past question 1). Replaces the hidden {"node_type":"meta"}
walked_path convention with real category/problem_text/pending_node columns on
l1_walk_sessions (migration 61dda4f615c6) — fixes junk proposals + off-by-one
depth cap (Findings 8,9), and pending_node replays the served node on re-mount
(no duplicate paid LLM call). Intake honors explicit flow_id and adhoc=True
(Findings 4,5); flow_proposals.l1_session_id FK -> CASCADE (Finding 6 time
bomb); L1 category GET is owner+admin like PATCH and require_account_owner_or_admin
delegates to User.can_manage_account (Finding 7); escalate falls back to default
recipients + filters deleted_at + warns when empty (Finding 10). Cleanups: dead
ticket_ref removed, IntakeResponse per-outcome validator, unused acknowledged
dropped, escalations partial index, restored a deleted audit assertion.
Full Phase 2A backend set: 110 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /accounts/me/l1-categories (require_l1_or_above) returns enabled + available
+ hard_floor; PATCH (require_account_owner_or_admin) sets the enabled set, dropping
unknown/hard-floored keys via l1_category_service. New L1CategoriesResponse/Update
schemas. 6 API tests green (incl. engineer + l1_tech write both 403); test_accounts
regression 36 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
An earlier anchor-edit silently failed, so POST /sessions/{id}/next-node and
GET /escalations were never added (they 404'd). Add both, anchored on the real
/escalate-without-walk route.
Phase-1 test_l1_endpoints tests used POST /intake to create adhoc setup sessions,
but Phase 2A intake now dispatches via match_or_build (build/matched/suggest/
out_of_scope — never adhoc). Add a _create_adhoc_session service helper and route
the step/notes/resolve/escalate/cross-account setup through it; rewrite
test_intake_adhoc as test_intake_build_creates_ai_build_session (mocked outcome).
All green: test_l1_endpoints + test_l1_api_ai_build = 25 passed; full Phase 2A
backend service/unit/model suite = 56 passed; notification suite = 18 passed.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- /intake now runs match_or_build (matched/suggest/out_of_scope/build); build
seeds the classified category as a hidden meta walked_path entry, matched starts
a flow session, suggest/out_of_scope return prompt data with no session.
- New POST /sessions/{id}/next-node (threads node_text to advance_ai_build) and
GET /escalations (engineer-or-above) for the handoff queue.
- New IntakeResponse(outcome=...)/NextNodeRequest/NextNodeResponse schemas and
require_account_owner_or_admin dep.
- Reconcile Phase-1 intake tests to the new contract (mock match_or_build); add
test_l1_api_ai_build.py covering build/out_of_scope/suggest/next-node/escalations.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Remove the weaker shadowing copies of the two T9 tests so the stronger
originals (which seed an engineer and assert eng.id in target_user_ids,
plus proposal_type/match_keywords) actually run.
- _resolve_recipients: treat an explicit empty target_user_ids as 'no
recipients' instead of falling back to the default owner/admin set.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Teaches l1_walk_sessions a new session_kind='ai_build' for AI-generated
decision-tree walks. FK shape matches adhoc: both flow_id and
flow_proposal_id must be NULL. Drops and recreates the two affected CHECK
constraints (session_kind allowlist + target_consistency). Migration
beca7464b6b4 chains from b3358ba0e48c.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Harden the Anthropic provider and lay the groundwork for schema-constrained
JSON, optimizing the existing claude-sonnet-4-6 / claude-haiku-4-5 usage
(no model changes).
ai_provider.py:
- _extract_text_from_response replaces fragile response.content[0].text:
skips non-text leading blocks (e.g. thinking), returns the first text
block, logs an anthropic.stop_reason warning on max_tokens/refusal
(truncation now observable), and raises ValueError on a no-text response.
- generate_json gains an optional `schema` param. Anthropic wires it to
output_config.format (structured outputs); schema=None preserves the exact
prior call for every existing caller. Gemini accepts-and-ignores it.
kb_conversion_service.py:
- TROUBLESHOOTING_SCHEMA / PROCEDURAL_SCHEMA + _schema_for_target_type(),
modelled as a strict superset of every field the prompts emit.
- convert_document passes the schema only when the new
AI_KB_CONVERT_STRUCTURED_OUTPUT setting is True (default False). The
_try_repair_json fallback stays as belt-and-suspenders.
Tests: 14 provider + 7 schema, TDD (red-green). Live constrained-decoding
smoke-test still required before enabling the flag in production.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sessions are account-scoped (per spec §7.9), not user-scoped, to support
team coverage. Comment-only fix surfaced by final review.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Per spec §5.6.1, audit rows are written at session terminal events
(resolve, escalate, escalate_without_walk). log_audit gains an optional
acting_as parameter that propagates the session's acting_as tag
('l1_coverage' for engineer coverers, null for native L1 users).
Final code review flagged this as Important — column existed but was
never populated. Four new integration tests cover all three paths.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
flip_stale_sessions flips L1WalkSession.status from 'active' to
'abandoned' for rows where last_step_at is older than 24h. Preserves the
row for audit; removes it from the L1 dashboard's 'Resume in progress'
widget. Runs hourly via APScheduler with max_instances=1 (Lesson 1).
Uses the admin session factory (no RLS context at startup).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mounts /api/v1/l1/* with require_l1_or_coverage on every route. Intake
creates an internal ticket and starts a flow OR adhoc session (PSA queue
merge follows in Phase 2). Step/notes/resolve/escalate delegate to
l1_session_service. escalate-without-walk creates an immediately-
escalated session for the BuildAbortedNoKB path.
ValueError from services → 400. Cross-account session access → 404.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
resolve: sets status=resolved, helpful, resolution_notes, resolved_at;
flips FlowProposal.validated_by_outcome on helpful=True proposal walks;
closes linked internal ticket. PSA close is a Phase 2 stub.
escalate: marks session + internal ticket as escalated. PSA reassign
deferred to Phase 2.
escalate_without_walk: creates an immediately-escalated adhoc session
with no walked_path, used by the BuildAbortedNoKB → Escalate path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
record_step appends to walked_path JSONB and advances current_node_id
on flow/proposal walks; refuses adhoc sessions. update_notes replaces
walk_notes (used by adhoc walks for debounced autosave); 256KB size cap
to prevent unbounded JSONB growth. Both reject non-active sessions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three start_* functions creating L1WalkSession rows with appropriate
session_kind and target id. Engineers acting in L1 mode get
acting_as='l1_coverage' for audit; native l1_tech users get acting_as=None.
step/notes (T13) and resolve/escalate (T14) extend this file next.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
T11 review caught that get_ticket was the one function without the *, marker
all other functions in the module use. One-line fix, no caller impact.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
create_ticket, update_status (sets resolved_at on resolve), get_ticket,
list_tickets_for_account (status filter, account-scoped), promote_to_psa.
Used by L1 intake when account has no PSA integration configured.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner-only endpoint to toggle can_cover_l1 on an engineer user. 422 if target
role is not engineer (owners/super_admins already see L1 surface; viewers/
l1_techs don't need this flag). 404 for cross-account targets.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Returns {engineer: SeatCheckResult, l1_tech: SeatCheckResult} for the
authenticated engineer's account. Powers the SeatCounterWidget UI in the
admin/users + account/users surfaces. Engineer+ access only.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- oauth.py: use status.HTTP_402_PAYMENT_REQUIRED constant (was raw 402)
- accounts.py bulk-invite: catch HTTPException separately to preserve
structured detail dict in failed-row error (was stringified repr,
unparseable by clients)
- Add bulk-invite per-row 402 test verifying structured error preserved
T8 code review identified these as Important issues. Functional change is
the bulk-invite fix; clients can now parse seat-limit errors from bulk
responses. 13/13 seat-enforcement tests pass.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
For engineer + l1_tech roles, check_seat_available is called at each
mutation point. Returns 402 Payment Required with structured detail
{code: 'seat_limit_exceeded', role, current, limit, upgrade_url} when
seats are full. Grandfathering: existing over-seated accounts keep
existing users; only new mutations are blocked.
Also updates AccountInviteCreate and AccountRoleUpdate schemas to
accept l1_tech as a valid role value.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Shared helper used by invite, accept-invite, and role-change endpoints
(integrated in T8). Counts active users by role against role-specific
seat limit on subscription (engineer → seat_limit, l1_tech → l1_seat_limit).
None limit = unlimited.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Aligns the model with the migration (T6 review caught: migration creates
ix_l1_walk_sessions_last_step_at but model annotation was missing, causing
schema drift if Base.metadata.create_all is used in tests).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Tenant-scoped fallback ticket model for accounts without PSA integration.
Tracks customer-name, problem-statement, status lifecycle (open/walking/
resolved/escalated), and optional links to flow/proposal/ai_session/
assigned engineer + PSA promotion ID. Account-scoped RLS policy uses
app.current_account_id session setting.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds source (NOT NULL, backfilled to 'manual_draft'), linked_ticket_id,
linked_ticket_kind, validated_by_outcome columns. CHECK constraints on
source values and linked_ticket_kind values. walked_path lives on the
new l1_walk_sessions table (Task 6) — NOT on FlowProposal.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds users.can_cover_l1, accounts.l1_seats_purchased, subscriptions.l1_seat_limit,
audit_logs.acting_as. Rotates the users.account_role CHECK constraint to include
'l1_tech' (was: 'owner', 'admin', 'engineer', 'viewer').
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Eighth commit in the session-expiration-policy series. Surfaces all
the owner controls and user-facing expiry UX that the prior commits
plumbed through, designed end-to-end via /plan-design-review (initial
4/10 -> final 9/10; 7 decisions locked in the plan).
Backend additions:
- accounts/me/security GET response gains active_users: list of
{user_id, name, email, last_login_at} for users in this account
with at least one un-revoked refresh token. Joined query on
refresh_tokens + users, distinct, ordered by last_login desc.
Drives the Active Sessions section.
Frontend additions:
- api/accountSecurity.ts: typed client for GET/PATCH/revoke-sessions.
- hooks/useAuthSessionExpiry.ts: reads idle/absolute expiry from the
auth store, returns warning ('none'|'soon'|'now') + reason
('idle'|'absolute') so consumers can pick the right UX for the
closer window. Re-evaluates every 30s.
- components/common/SessionExpiryToast.tsx: top-of-app notice that
fires at T-5min. Idle case: warning-amber tone, [Stay signed in]
button hits authApi.refresh() and updates the store on success.
Absolute case: info-cyan tone, [Sign in now] link to /login (no
recoverable action). Dismissable, doesn't re-fire after dismissal.
- components/account/RevokeSessionsModal.tsx: confirmation modal for
the two bulk-revoke scopes. Title, body, and confirm-label vary by
scope; danger-styled confirm button.
- pages/account/AccountSecuritySettingsPage.tsx: the main page.
Header (Shield icon), intro, Policy card with Strict/Standard/Custom
radios + always-visible-disabled Custom inputs (idle/absolute
minutes) with inline validation, Save button + emerald success ping,
info note about 'applies at next login'. Active sessions card with
count-aware copy, list of {name, email, last-login-ago} rows
(caller tagged '(you)'), two buttons — 'except me' hidden when
count=1, 'sign me out and everyone else' uses danger-tinted styling.
- pages/AccountSettingsPage.tsx: 'Session security' row added to the
owner-only settings list.
- router.tsx: /account/security route, owner-gated via ProtectedRoute.
- pages/LoginPage.tsx: cyan info-tone banner above form when
?reason=session_expired is in the URL.
- components/layout/AppLayout.tsx: mounts <SessionExpiryToast />.
Scope=all bulk-revoke UX (the most jarring moment): on success,
toast.success(N sessions), 1.5s delay, then clear localStorage +
useAuthStore.logout() + window.location='/login' (no banner — the
owner just did this).
Backend tests: existing 22/22 still green plus the GET test now
asserts active_users is present + non-empty after login. Frontend:
tsc clean, authStore test 2/2.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sixth commit in the session-expiration-policy series. The kill-all-
sessions endpoint folded into scope after the §4.11 design pass.
- POST /accounts/me/security/revoke-sessions, owner-only.
- Body: {"scope": "all" | "others"}. Default "all" includes the caller's
own refresh token. "others" preserves the caller's sessions so an
owner can sign everyone else out without logging themselves out.
- Single SQL UPDATE through users.account_id -> refresh_tokens, with
revoked_at IS NULL preserved as the gate so already-revoked rows
don't get double-stamped (the idempotency property).
- Caller's access token is not touched — it dies on its 5-minute timer.
Frontend handles "scope=all" UX by clearing localStorage and
redirecting after the response (commit 8).
- Affected users' next /auth/refresh hits the existing atomic-revoke
zero-rows path -> invalid_refresh_token (plain logout, no banner).
- Writes one account.sessions_revoked_bulk audit event with
{scope, revoked_count}.
Tests added in test_session_policy.py (6 cases):
- #17 scope=all kills caller's own session; their refresh -> 401
invalid_refresh_token.
- #18 scope=others preserves caller's session; their refresh succeeds,
member's refresh -> 401 invalid_refresh_token.
- #19 account-scoped: test_admin in a different account is unaffected
when test_user's owner runs revoke-all (revoked_count=1, not 2).
- #20 engineer-role member -> 403.
- #21 emits exactly one audit row with the expected payload.
- #22 idempotent: second immediate POST returns revoked_count=0.
22/22 in test_session_policy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fifth commit in the session-expiration-policy series. Surfaces the
session-policy override controls to account owners.
- schemas/account_security.py: NEW. SessionPolicyResponse returns both
the override (Optional[int]) and the effective value (always present)
plus the system min/max bounds, so the frontend can render the
Custom-preset form without re-implementing the defaults logic.
SessionPolicyUpdateRequest accepts NULL to clear an override.
- endpoints/account_security.py: NEW. GET and PATCH on /me/security.
Owner-only via require_account_owner. PATCH validates per-field
bounds, then validates the effective idle <= absolute invariant
(catching the partial-override case the DB CHECK can't see), then
writes the row + an account.session_policy_update audit event with
old/new/effective_old/effective_new payload.
- router.py: registers the new router under _tenant_deps next to
accounts.router.
Tests added in test_session_policy.py (8 cases):
- GET returns NULL overrides + Strict defaults + system bounds.
- PATCH persists override; next login JWT reflects new values
(60min/240min -> idle_max=3600, abs_max=14400 seconds).
- PATCH rejects idle < min (422).
- PATCH rejects absolute > max (422).
- PATCH rejects idle > absolute when both are set (422).
- PATCH rejects partial override that produces effective idle >
effective absolute (idle=43200, absolute=NULL with default 20160).
- Engineer-role user gets 403.
- PATCH writes exactly one audit row with the expected payload shape.
16/16 in test_session_policy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fourth commit in the session-expiration-policy series. The gate that
ends "logged in forever" — refresh now rejects tokens whose original
login (auth_time) is older than abs_max seconds.
Algorithm (plan §4.5):
1. Decode JWT (dep already handles idle expiry).
2. Load user; reject inactive/missing as invalid_refresh_token.
3. Resolve effective auth_time/idle_max/abs_max, grandfathering
pre-PR tokens by snapshotting current account policy.
4. Atomically revoke the JTI regardless of outcome — this consumes
the token whether or not the absolute check passes, so an
absolute-expired token cannot be replayed forever.
5. If the atomic UPDATE matched zero rows -> invalid_refresh_token.
6. If now >= auth_time + abs_max -> commit the revoke explicitly
(so it survives the rollback hook in get_admin_db) and 401
session_expired_absolute.
7. Otherwise mint via _mint_with_claims, carrying claims forward.
Boundary check uses `>=`, not `>` — a deadline equal to now is
expired. _refresh_session_tokens (commit 3) replaced by two narrower
helpers: _resolve_refresh_claims (grandfather logic, no mint) and
_mint_with_claims (mint with explicit claims, no grandfather). Makes
the endpoint's algorithm read top-down without indirection.
Tests added in test_session_policy.py:
- #8: backdate auth_time by exactly abs_max -> session_expired_absolute
at the deadline boundary.
- #9: same token tried twice; first returns session_expired_absolute
AND consumes the row; second returns invalid_refresh_token.
- #12: legacy token without auth_time/idle_max/abs_max gets one
successful rotation; new JWT carries fresh policy snapshot from
the account (3d/14d defaults under Strict).
25/25 across test_session_policy + test_auth + test_oauth_callbacks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Third commit in the session-expiration-policy series. Every refresh token
issued from now on carries the policy snapshot in its JWT (in seconds,
for direct Unix math), and every login/OAuth response surfaces both
expiry windows as ISO timestamps. /auth/refresh carries the claims
forward unchanged — including auth_time, which never resets on rotation.
Does NOT yet enforce the absolute cap — that's commit 4, sequenced so
the gate can be reverted independently if pilots hit an edge case.
But the wire is fully populated, and a grandfather path is already in
_refresh_session_tokens for tokens issued before this PR.
Key changes:
- core/security.py: create_refresh_token signature changes to
(user_id, *, auth_time, idle_max_seconds, abs_max_seconds). Adds
resolve_session_policy(account) -> (idle_minutes, absolute_minutes)
applying defaults for NULL overrides.
- schemas/token.py + schemas/oauth.py: Token and OAuthCallbackResponse
gain idle_expires_at + absolute_expires_at (Optional[datetime],
Pydantic emits ISO 8601 UTC strings).
- endpoints/auth.py: new _mint_session_tokens(user, db) and
_refresh_session_tokens(payload, user, db) helpers. /auth/login,
/auth/login/json, and /auth/refresh now route through them. The
refresh endpoint's pre-existing "Refresh token has been revoked"
error normalized to the taxonomy detail "invalid_refresh_token".
- endpoints/oauth.py: both Google and Microsoft callbacks call
_mint_session_tokens; OAuthCallbackResponse carries the expiry
fields through.
- tests: two new cases in test_session_policy.py — login_json embeds
the claims with strict defaults (3d/14d -> 259200/1209600 sec) and
surfaces matching ISO expiry fields; refresh carries auth_time,
idle_max, abs_max forward unchanged across rotation.
35/35 across test_session_policy + test_auth + test_oauth_callbacks +
test_account_invite_lookup + test_account_management.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Second commit in the session-expiration-policy series. Lands the
error-detail taxonomy from §4.10 of the plan; no UI-visible change yet
because the frontend interceptor (commit 7) doesn't read the new detail
strings, but the wire is now ready for it.
Today every /auth/refresh failure returns 401 "Invalid refresh token"
regardless of cause, so the frontend has no way to distinguish "your
session ended for security" from "we don't recognize this token at
all." This commit introduces:
- decode_refresh_token_strict(): wraps jose.jwt.decode and raises a new
IdleTokenExpired exception (from ExpiredSignatureError) so callers
can branch on idle expiry. All other jose failures still propagate
as JWTError. The legacy decode_token() is preserved for access-token,
password-reset, and email-verification paths that don't need the
distinction.
- get_refresh_token_payload(): now maps IdleTokenExpired ->
"session_expired_idle", JWTError and wrong-type tokens ->
"invalid_refresh_token".
- test_session_policy.py: new test file (will accumulate cases across
the series). Three tests for the taxonomy: idle-expired returns
session_expired_idle; wrong type returns invalid_refresh_token; bad
signature returns invalid_refresh_token.
20/20 across test_session_policy + test_auth + test_oauth_callbacks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First commit in the session-expiration-policy series (see
docs/plans/2026-05-13-session-expiration-policy.md). No behavior change
yet — this lays the schema + settings groundwork only.
- Settings: SESSION_IDLE_MINUTES_DEFAULT=4320 (3d),
SESSION_ABSOLUTE_MINUTES_DEFAULT=20160 (14d), plus MIN/MAX bounds
so account overrides have envelopes (15min..30d idle, 1h..90d
absolute).
- accounts table: nullable session_idle_minutes and
session_absolute_minutes columns (NULL = use system default), plus
a CHECK constraint that rejects idle > absolute when both are set.
Partial-override validation lives at the app layer because the DB
cannot read Settings.
Subsequent commits will: distinguish idle vs invalid-token expiry on
the wire, embed auth_time/idle_max/abs_max in refresh JWTs, enforce
the absolute cap in /auth/refresh, add the owner-only policy +
bulk-revoke endpoints, and surface everything in an AccountSecurity
settings page with a session-expiry toast.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>