Open-ended working profile — implementation plan
Status: implemented (source; 2026-09-15) · Author: Astra (omp orchestrator seat); design approved by kwiss 2026-09-14 · Reviews: final independent Astra + fresh Fable (Devin) CLEAN, orchestrator-observed · Acceptance: completed local gates PASS with main c767e888 in the open index; native Cowork manual checks deferred to kwiss after merge; prior 10 CI checks green on f6d95c6e are stale, refreshed CI pending new push · Release: dispatcher owns recorded authorized eventual squash merge; branch changelog SHA 7981c3f3 needs normal post-squash main SHA follow-up before any deploy; no deploy authorized · Related: workflow-intelligence design §10, PR #327, PR #356; NOS-346 already Done (V1 shipped 2026-09-02), not this lane's current issue. Original implementation steps and historical addenda below are preserved; latest decision takes precedence.
Why
The interview shipped as a fixed bank reachable only when a host model infers north__working_profile or the lawyer knows /north:onboard. Preprod: 2 read calls ever, 0 answers saved, 0 calls from the claude.ai host since #327. The §10 natural-language door (plugin skill) was never built. The user's failing turn left no trace; its precise cause is unknown. This plan makes the profile open-ended, captured in conversation on three entrypoints, with the bank as optional guidance, and changes descriptions, prompt and instructions together.
Decisions (settled by kwiss; not open for review)
- Three entrypoints: first onboarding; a lawyer-requested profile discussion at any time; a relevant proactive follow-up after completed work.
- Bank questions are optional, adaptable guidance. Never a whitelist of what may be asked or saved; never compulsory completion; the profile is never "complete". First onboarding need not pick a bank question.
- Tools kept:
north__working_profile(read) andnorth__working_profile_answer(save). No standalone skill, norampchange, no product web-chat tool, no extra approval or prior-read gate on a direct custom save. question_idis an optional, bounded, opaque free string (validated length and charset, NOT an enum). Explicit ids let a lawyer keep distinct contexts for identical wording.- Absent id → reserved auto namespace:
auto:<sha256 hex>of the exact sanitized persisted question text. Not the answer, not a slug, no fuzzy semantics. The exact same question without an explicit id is the same owner entry. Callers cannot mintauto:ids; a returned auto id may be reused only to update or retry that existing row. - Question text is required for every new entry, onboarding included. Legacy pre-deployment rows have null text: reads fall back to the bank text by id, else the explicit label
legacy question (text not recorded). No data backfill. - Schema: composite key
(organization_id, owner_user_id, question_id)kept; one additive nullablequestion_textcolumn; no capture-kind column; migration frombun db:generateonly. - Versioning covers the question + answer pair. Equality of both fields is a no-op; a question-only correction is a new version; a save with
expected_versionwhose target row is absent never creates; an exact retry of an already-committed update succeeds as a no-op even with the now-staleexpected_version. - Reads return all persisted rows for the owner, independent of the bank; optional suggestions are a separate field.
- Admin: the silent
LIMIT 100in the admin projection is removed with no pagination architecture; a 101-row proof guards it. - Provisioning: drop and re-create the two-argument
working_profile_answers_for_admininside the existing transactional provision unit, preservingOWNER TO "forma", pinnedsearch_path,REVOKE ALL FROM PUBLIC,GRANT EXECUTE TO app_role; noCASCADE. - Audit: an arbitrary id is recorded as fingerprint + length; question and answer bodies are never logged on any path, including conflict and rate-limit outcomes.
- Sanitization: question text passes
sanitizeWriteLineand answerssanitizeWriteBody, the existing free-text boundary; empty after sanitization is rejected. - Disclosure: the save description and server guidance state model transcription and workspace-manager visibility for direct custom saves too.
- Memory promotion is out of scope. Fresh-session learning consults the profile (read tool) alongside
north__memory_recall; nothing is promoted into memory by this plan. - No live writes, synthetic accounts only (
chris.northos.comandchris.cdumont.hcresolved to zero exact matches; nothing guessed). No merge, no deploy;MERGE-READYis terminal.
Contracts
Save — north__working_profile_answer
| Input | Rule |
|---|---|
question | Required at the Zod boundary. Sanitized by sanitizeWriteLine; empty after sanitization or over 500 characters → rejected with the standard NORTH-prefixed receipt. |
question_id | Optional. Explicit: opaque safe token, at most 128 characters, outside the auto: namespace. Absent: auto: + SHA-256 of the sanitized question. A client-supplied auto: id must match an existing row for this owner, else rejected. |
answer | Required, sanitized by sanitizeWriteBody as today. |
expected_version | Optional. Outcome is fixed by the state table below; the tool never guesses. |
| Receipt | question_id (auto or explicit), answer_version, outcome ∈ created | updated | noop | conflict | rejected. Bodies never echoed into audit. |
expected_version | Target row | Question + answer pair | Outcome |
|---|---|---|---|
| absent | absent | — | create, subject to the reserved-id rule (a caller-supplied auto: id with no row → rejected) |
| absent | present | equal on both fields | no-op, current version returned |
| absent | present | differs on either field | conflict, current version returned; caller re-reads and resubmits with expected_version |
| matches current | present | differs on either field (question-only included) | update, version + 1, same id (auto id unchanged) |
| matches current | present | equal on both fields | no-op |
| stale | present | equal to the committed pair (exact retry) | no-op success, version and timestamps unchanged |
| stale | present | differs | conflict |
| supplied (any) | absent | — | rejected, nothing created |
Compatibility split. The database layer stays one-release compatible: question_text is nullable, so a not-yet-upgraded writer may still insert without it and the old explicit-column admin read keeps working. The MCP contract intentionally requires question at the Zod input boundary. A host calling from a cached old tool schema without question fails SDK schema validation before the handler runs and before any audit row is written; the client must refresh its tool catalogue. No shim, no server-side fabricated question, and no custom receipt for this path: the standard NORTH-prefixed rejected receipt is reserved for handler-level rejections (empty after sanitization, reserved-id violations, missing correction target).
Bounds. question: passed through the existing sanitizeWriteLine (apps/mcp-server/src/write-text.ts), then 1..500 characters, no truncation (over 500 → rejected). answer: sanitizeWriteBody, 1..10,000 as today. question_id: opaque safe token of at most 128 characters, which admits a returned auto:<64 hex>; never the bank enum. The auto id hashes exactly the sanitizeWriteLine output that is persisted as question_text; identity normalization is nothing beyond that sanitizer.
Read — north__working_profile
view=all returns every persisted row (id, question text or fallback label, answer, version, updated_at); view=next returns the same profile plus suggested_questions[] from the adapted bank minus ids already answered, as guidance only. No completion flag.
Doors
Tool descriptions, NORTH_ONBOARD_PROMPT and the server-instruction line change in one commit and share one vocabulary: onboarding, "how I work", follow-up after completed work, custom questions welcome, one question at a time, save immediately, model transcription and manager visibility disclosed. Tests that pin exact prompt or instruction strings are deleted or rewritten to assert behaviour (tool names present, disclosure present, no completion gate), per repo rules; each deletion is accounted for in the PR body. No test re-pins exact wording.
Files
packages/db/src/schema/working_profile.ts(column), generated migration,packages/db/src/working-profile.ts(upsert rules, read all, admin read without limit),packages/db/scripts/provision-roles.ts(function drop/recreate), affectedpackages/db/src/working-profile*.test.ts.packages/agent-runtime/src/working-profile.tsandindex.tsexports.apps/mcp-server/src/tools/working-profile.ts,working-profile-answer.ts,mcp-app.ts(prompt),server-instructions.ts; teststools-working-profile.test.ts,mcp-protocol.test.ts,host-approval-guidance.test.ts, new integration suite.apps/web/lib/working-profiles.ts,apps/web/app/(settings)/working-profiles/page.tsx(question column shows persisted text or fallback label; copy states rows are model-transcribed), their tests.
Seat routing and caps (unchanged configured seats)
- Implementation: Fable 5.1 worker in Claude Code, effort high (
FABLE_ALLOW_DIRECT_EXECUTION=1), owns product code. The acceptance and unit test implementation goes to the configured Grokmechanicalseat (effort medium), a separate non-Claude worker per the substantial-Fable-code policy, so the product owner never writes its own checks. The orchestrator writes no product code. - Reviews to convergence: omp
reviewer(Astra, high) and a fresh Fable high session with no implementation context;review-rls(Sol, max) for schema and provisioning;deletions(Grok) on removed lines and deleted tests. - Worker cap, re-estimated once with the orchestrator after plan review: 28 product/test files and 3,200 changed lines, excluding the generated migration, docs and throwaway acceptance scripts. Reason: the plan touches every consumer (MCP tools, runtime, DB, provisioning, admin lib and page) plus 25 acceptance cases; scope is unchanged. This is a recorded cap exception, not a loophole for excluding tests; exceeding it again → stop and re-estimate.
- Schema slot not granted here; the worker coordinates before
bun db:generate; one agent on the schema per wave.
Steps
- Plan review (Astra high + fresh Fable high) on this page; reviewers challenge red-capability of every acceptance check.
- DB: column, generated migration, upsert and read rules, admin read without limit, provisioning unit; unit tests for versioning, auto-id, sanitization, 101 rows.
- Runtime exports.
- MCP tools + prompt + instructions in one commit; wording-pinning tests deleted/rewritten.
- Admin page and lib.
- Acceptance suites (below) green on a fresh clone; failures reported, never thresholded away.
- Rollout order, recorded for whoever deploys later: generated migration → transactional provisioning (
DB_APP_LOGIN_USER=… provision) → new app traffic. This plan authorizes no deployment. - Gates (
bun run lint,bun run typecheck, tests of db, agent-runtime, mcp-server, web), dual review, status flips, PRfeat(working-profile): open-ended profile capture, CI green,MERGE-READY.
Acceptance
Route. Fresh synthetic isolated database (clone on the dev Postgres, drizzle-kit migrate, DB_APP_LOGIN_USER=… provision-roles.ts), real buildMcpApp with the synthetic injected authResolver and real chassis (audit ledger, real Redis-free limiter stub where noted, real limiter for the rate-limit case) from apps/mcp-server/test/org-isolation.integration.test.ts, reached through built.app.request. No shared dev server, no canned handlers.
Tier A — conversational, actual Claude client. A test-only stdio adapter process boots that app and forwards MCP frames to app.request; Claude Code is launched in a visible herdr pane with --mcp-config pointing at the adapter, and the lawyer turns are typed there. The bare Messages API is not a substitute for these cases. Every declared run must satisfy the observable contract (trace + DB state); a miss is reported as an unresolved failure with its trace, never absorbed by a success threshold. Model wording is free; the contract is what was called, saved and read. A host that calls north__briefing or north__memory_recall first is correct.
Tier B — deterministic, SDK client. The MCP SDK Client over app.request, no model, asserting exact receipts, rows, versions, audit rows and RLS. Each check names the mutation that turns it red.
Untested by both tiers, stated plainly: OAuth consent and token scoping (resolver injected), the claude.ai / Cowork host prompt and tool rendering, host deferred-tool mode, the lawyer's real memories in the briefing.
Original cases (each kept separately)
| # | Case | Question → answer → save → read; contract | Tier |
|---|---|---|---|
| 1 | First onboarding | "onboard moi sur north" → read (profile empty, suggestions present) or a direct question → one question asked → save with question text → read shows the entry. Bank question not required. | A + B |
| 2 | Adapted questions | Agent reformulates or replaces a suggested onboarding question in its own words → save carries the actual asked wording; a bank-id save with different wording persists that wording under the bank id; read shows the stored text. | A |
| 3 | Requested continuation | "let's continue my profile" with 3 rows → read returns all 3 plus remaining suggestions → user answers → save → post-save read shows 4 rows. | A + B |
| 4 | Proactive after task | A synthetic task completes with no interview cue and no volunteered preference → the model itself initiates one follow-up question grounded in the task just done → user answers → save (no prior read required) with the question actually asked, receipt outcome created → post-save read shows the entry. | A |
| 5 | Unrelated custom question | Volunteered preference unrelated to the bank → custom save → read lists it in profile, not in suggestions; admin projection shows it. | A + B |
| 6 | New-session read / continuation | Actual fresh Claude conversation with no previous transcript, same principal → model reads the profile → informed continuation that does not re-ask an answered entry → user answers → save → post-save read shows prior rows plus the new one. B: answered suggestion ids excluded from suggestions. | A + B |
| 7 | Correction / stale conflict | Read all → save same id with expected_version=1 → version 2 → save with stale 1 → conflict receipt, row unchanged → agent re-reads before retrying. | A + B |
| 8 | Retry idempotency | Identical save twice → one row, one version, second receipt noop. | B |
| 9 | Decline / stop | Lawyer declines → zero rows written, no second offer in the conversation. | A + B (row count) |
| 10 | Unknown answers, no fabrication | "I don't know" → no durable fact created, or a row whose answer records the lawyer's own uncertainty. Judged by a user-fact oracle over the transcript (does the saved answer assert a fact the lawyer did not state?), not string containment. The oracle is a deterministic structured-fact checker where that suffices; an LLM judge only if it does not. Calibration is part of the case: a planted fabricated answer must fail the checker and a known faithful paraphrase must pass, or the oracle is not accepted. | A + B (row content) |
| 11 | Continue after all suggestions answered | Suggestions empty → profile still returned → user answers a new custom question → save succeeds → post-save read shows it; no completion gate anywhere. | A + B |
| 12 | Isolation / read-only scope | Second synthetic org/user reads empty under RLS; read-only resolver lists neither tool and no prompt. | B |
Boundary cases (added)
| # | Case | Contract | Tier |
|---|---|---|---|
| 13 | Identical wording, distinct explicit contexts | Same question text saved with ids ctx-a and ctx-b → two rows; the same text without id → the single auto: row, untouched by the explicit ones. | B |
| 14 | Question-only correction / same-id collision | Unversioned save on an existing id with a different pair → conflict, row unchanged. Versioned question-only update (same id, matching expected_version, changed question, same answer) → version + 1. The auto id returned by a create is reused for a versioned question correction and for its exact retry → same id, expected outcomes. A caller-supplied auto: id with no existing row → rejected. Explicit id equal to another owner's id → isolated by key. | B |
| 15 | Missing correction target | expected_version supplied for an absent id → rejected, no row created. | B |
| 16 | Concurrent creates / corrections | Parallel identical id-less creates → one row. Competing corrections from the same version with DIFFERENT pairs → exactly one update, one conflict. Competing corrections with the IDENTICAL pair → one update, one no-op. | B |
| 17 | Lost-response committed retry | Id-less create committed but the response carrying the generated auto id is lost → retry of the original id-less payload → same auto id, one row, version 1, timestamps unchanged. Then a committed correction whose response is lost → exact retry with the now-stale expected_version → no-op success, version and timestamps unchanged. | B |
| 18 | Real rate-limit recovery | Real Redis limiter on a dedicated disposable Redis instance (or unique synthetic-only keys) with writePerMin=1, never the production or shared-dev Redis and never real-user keys → a real rejected save (rate-limit receipt, no row) → agent retains the answer and retries the same save after the window expires, bounded retry → row created once. | A + B |
| 19 | Legacy / retired / >100 reads | Null-text legacy row reads with bank fallback; a row whose bank id was retired reads with the explicit legacy label; 101 rows return in full on both the tool read and the admin projection. | B |
| 20 | Audit sentinel redaction | Question and answer containing a unique sentinel → no audit row on any outcome (ok, conflict, rejected, rate-limited) contains the sentinel; arbitrary id appears only as fingerprint + length. | B |
| 21 | Direct-save disclosure / bounds | First custom save in a conversation is preceded by the transcription and manager-visibility disclosure. Deterministic: question empty after sanitization → rejected, no row; question of 501 characters → rejected, no truncation, no row; id of 129 characters → rejected; a 500-character question and a 128-character id succeed. | A + B |
| 22 | Skip vs stop vs later vs permission denied | Actual Claude behaviours (Tier A): "skip this one" → no row, next suggestion offered; "stop" → no further offer; "later" → resume in a new session shows prior rows. Permission denial cannot be exercised through the host UI under --dangerously-skip-permissions: the adapter injects a protocol-level permission-denied result for the write so the real handler never executes; assert no row and no "saved" claim in the reply. The actual Cowork approval UI stays untested; no manual approval pane is launched. | A (+ adapter injection) |
| 23 | Same org, two write principals | Two write-scoped users in one org each see only their own rows through the tools; the owner wall holds on writes. | B |
| 24 | Admin vs ordinary member | Workspace manager reads all members' rows on the admin surface; an ordinary member is refused; the page renders persisted text and the fallback label. | B (web) |
| 25 | Fresh + upgrade + rerun provisioning | Fresh install creates the function; a database holding the pre-change function upgrades without CASCADE and keeps owner, search_path, revoke and grant; running provisioning twice is a no-op. Against the expanded schema and new function, the OLD explicit-column admin read and an OLD-shape SQL write (no question_text) both succeed; the NEW reader then displays that legacy row with the fallback label. | B (db) |
Review focus
- Every Tier B check names its red mutation; Tier A traces are attached verbatim (redacted per audit rules).
- Deletions pass: each removed test or assertion is justified in the PR body; wording pins are not re-added.
- RLS pass (Sol max): policies unchanged, no-FORCE-RLS dependency of the admin function documented at both ends, provisioning transactional.
Historical B1 addendum: the gate/status claims in this earlier snapshot are superseded by the 2026-09-15 decision in the direction addendum below; failures, incidents and counts remain historical evidence, not current PR blockers.