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>
End-to-end through the real endpoint+service stack (only the AI boundary mocked:
match_or_build outcome + ai_tree_builder.generate_next_node). Asserts the captured
FlowProposal is outcome-validated with l1_session_id set / source_session_id null
and tree root 'n1' (meta entry skipped); and that escalate notifies the account's
engineers and the session surfaces in GET /l1/escalations. 2 passed.
Co-Authored-By: Claude Opus 4.7 <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>
L1WalkSession has no escalated_at column (only started_at/last_step_at/resolved_at
+ escalation_reason[_category]). The /escalations endpoint and its test referenced
escalated_at, which would AttributeError at query time / TypeError at construction.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
KNOWN-RED (handoff): test_escalations_forbidden_for_l1_tech passes; the intake/
next-node tests still 403 'L1 access required' despite the DB role persisting as
l1_tech (verified) and get_current_user reading role from the DB. The identical
register->promote->subscribe->login helper works in test_l1_endpoints.py, so this
is a test-harness/auth interaction needing interactive debugging in a clean shell.
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>
Add test_resolve_ai_build_creates_outcome_validated_proposal and
test_escalate_notifies_engineers to cover the already-committed
Task 9 implementation (flywheel FlowProposal creation on resolve,
notify() call on escalate). Adapts fixture pattern to test_db +
_make_internal_ticket as required by the T9 spec.
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>
Full backend suite (1325/1325 passing, xdist) + L1-specific tests
(57/57) + L1 RLS tests (8/8) + frontend build (tsc clean, vite clean)
+ migration roundtrip results. Per-line checklist against spec §15.
Known Phase 2/3 items explicitly deferred per plan scope section.
fix(test): RLS fixture users INSERT missing NOT NULL columns
test_l1_rls.py and test_rls_isolation.py seeded users without the
five NOT NULL columns added in prior migrations (is_super_admin,
is_team_admin, is_service_account, must_change_password, timezone).
Also adds DROP SCHEMA before alembic upgrade in _ensure_rls_schema
to prevent DuplicateTable errors when create_all tables are present.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
l1-workspace.spec.ts covers:
- L1 user lands on /l1, intakes a problem, takes notes (autosave), resolves
- L1 cannot access /pilot, /trees/new, /escalations (route guards)
- Engineer with can_cover_l1 sees the L1 Workspace nav + coverage banner
- escalate-without-walk path via direct API call returns escalated session
Seed script adds l1@resolutionflow.example.com (l1_tech) and
engineer-coverage@resolutionflow.example.com (engineer + can_cover_l1).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds 8 synchronous psycopg2-based tests that connect as resolutionflow_app
and verify the tenant_isolation RLS policies (USING + WITH CHECK) on the two
new L1 Phase 1 tables block cross-tenant reads and reject cross-tenant INSERTs.
Uses psycopg2 (not asyncpg) to avoid the conftest pytest_runtest_teardown hook
that closes the asyncio event loop after every test — incompatible with
module-scoped asyncpg fixtures in pytest-asyncio 0.24.
conftest.py: extends _RLS_TEST_FILES set to include test_l1_rls.py so it is
excluded from the default create_all test suite (requires RUN_RLS_TESTS=1).
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>