Twins
Persona twins are a program capability, not a fifth product. Names are fixed in PRODUCT-CONTRACT.md
section 11.4 and recorded as built in 11.7; the design is PRODUCT-PLAN.md Part 7. Every item carries the
release it belongs to: R3 twins for program personas, R5 stage gates, R6 organisation twins (contract 12.7 and ADR 0032; the R4 release is the workbench, on its own pages).
The R3 items are built in the repositories and every R3 item still carries the status word
designed until the release: twins move to preview only when the R3 gates pass
(contract 11.1 and Part 7 section 9), and this page changes its chips in the same commit as forge.config.json,
the contract and CLAIMS-REGISTER.md. Pins verified in source on 2026-09-15: 87 permissions
(EXPECTED_PERMISSION_COUNT), persona version 3 (PERSONA_VERSION), 189 routes in
api/scripts/designed-routes.json (EXPECTED_ROUTE_COUNT re-pins to 189 in the same change), migrations
0004_chain and 0005_twins (HEAD = "0005", 60 tables), tone map 61. R5 and R6 items are
designed in the older sense: nothing of them exists in code yet, and their
numbers are targets.
Renumbered on 2026-09-16 by ADR 0032
and contract 12.7: contract 11.4 placed stage gates in R4 as 0006_stage_gates (tables -> 62, routes 189 -> 196,
tone map 62, keys +4 -> 91, persona version 4). Section 12 supersedes those lines: R4 is the workbench release
(0006_workbench, tables -> 63, routes -> 206, no new permission key, persona version 3), stage gates ship in
R5 as 0008_stage_gates after 0007_lenses, and organisation twins move to R6. Every release label and
count on this page carries the section 12 numbering; contract 11.4 keeps the original lines as history. The
roadmap and the statuses page carry the same numbers.
Phase 4 also fixes the wording of every proposal footer: “Proposed by <Name>’s twin (agent, working as
<workbench role>); decided by <Name>” (workbench roles), and the
decline reason is entered inline in the item, not in a separate dialog.
Tables
| Table | Release | Purpose | Key columns |
|---|---|---|---|
audit_chain | R3 (0004_chain, table only) | the hash chain over audit_events; the worker job chain_append stays disabled until R2’s chain work lands | append-only, insert and read only for the application role |
twin_definitions | R3 | one twin per persona, person and program | organization_id, program_id (nullable for R6), user_id, persona_key, agent_key (console), status, duties jsonb, tool_allowlist_hash, expiry_hours (72), digest_time, batch_times, budget_tool_calls_month, budget_usd_month (nullable), paused_by, paused_reason, retired_at; unique (program_id, user_id, persona_key) with nulls not distinct; partial unique (program_id, user_id) while active |
twin_triggers | R3 | a duty’s schedule, event, hand-off or mention trigger | twin_id, duty_key, kind, event_action (constrained to TRIGGERABLE_ACTIONS), spec jsonb, min_interval_seconds (3600), priority, standing_plan_json, standing_plan_hash, plan_interrupt_id, enabled, last_fired_at, fire_count |
twin_schedules | R3 | next run per trigger | twin_id, trigger_id, rrule, next_run_at, last_run_at, locked_at |
inbox_items | R3 | transactional pointers to what waits for a person | user_id, organization_id, program_id, kind, source_type, source_id, interrupt_id, twin_id, trigger_id, priority, batch_key, due_at, reminded_at, status, snoozed_until, delegated_from_user_id, feedback, done_by, done_at |
handoff_requests | R3 | the only channel between twins, created sent on approval | from_user_id, from_twin_id, to_persona_key, to_user_id, kind, linked_type, linked_id, message_md (redacted), status, thread_id, decided_by, decided_at, reason; unique partial index on (linked_type, linked_id, to_persona_key, kind) while sent or accepted |
delegations | R3 | one-hop routing of write approvals | user_id, delegate_user_id (must differ), program_id (nullable), starts_at, ends_at (at most 30 days), reason, renewal_count, revoked_at, status |
stage_gates | R5 | the only writer of programs.current_stage_id | program_id, stage_id, version, status, required_records jsonb, brief_artifact_id, brief_version, opened_at, ready_by, ready_at, review_signature_id, close_approval_id, close_signature_id, closed_at, next_stage_id, supersedes_id; unique (program_id, stage_id, version) |
review_audit_reviews | R5 | QA’s monthly oversight summary, counts and ids only | program_id, period_start, period_end, summary jsonb, artifact_id, accepted_by, accepted_at |
Table counts: 53 after 0003_public; 54 after 0004_chain (audit_chain, table only, because R2 had not
shipped when R3 started); 60 after 0005_twins (the six R3 tables). test_migrations.py pins
HEAD = "0005" and EXPECTED_TABLE_COUNT = 60, alembic check is clean, and the round trip head -> 0003
-> 0001 -> head -> base -> head is green. R4 adds the three workbench tables as 0006_workbench (63); R5 adds 0007_lenses (65) and the two stage-gate tables as 0008_stage_gates (67); R6 adds 0009_user_grants (68). Part 7 quotes 57 and 59 because it counts
without the three public tables and before the chain table.
Columns added by 0005_twins: agent_runs + twin_id, trigger_id, initiated_by, priority (0 for a
user-requested run, 10 for a twin), target_id, with a partial unique index on (trigger_id, target_id) while
queued or running; run_interrupts + detached; tool_calls + basis_hash; notifications +
deliver_after; messages + mentions; audit_events + batch_id, twin_id, trigger_id,
delegated_from_user_id with a CHECK that the last is null whenever signature_id is set (the four are left
out of the row hash while null, so rows written before R3 keep verifying); users.settings + quiet_hours,
digest_time, timezone, inbox_shortcuts, illustrative; programs.settings + twin_defaults
(max_open_items, decline_rate_threshold, citation_coverage_min), escalation_days. threads.schedule was
migrated then dropped: each phase-2 schedule became a twin paused with paused_reason = migrated, the
persona resolved from the owner’s program roles including groups (lead, then scientist, quality reviewer,
manufacturing specialist; owner-only rows map to program_owner), the schedule stored as a disabled trigger with
no standing plan, a system twin.create audit row and an information inbox item “A schedule from phase 2
was moved to your twin; review it and resume”. Nothing runs until the person approves a standing plan and
resumes. R5: programs.settings + gate_critical_milestone_tag.
Permission keys
Grammar product.resource.action, closed verbs, appended after program.agent_memory.manage in catalogue
order. Counts: 87 at R3 and at R4 (in code; R4 adds no permission key, contract 12.6), 93 at R5 (the four stage-gate keys below plus org.role_memory.read | manage), one more at R6 (org.twins.use).
| Key | Release | Description | Held by (persona) |
|---|---|---|---|
program.twins.read | R3 | Read every twin of the program and its activity, for oversight | program_owner, program_lead, quality_reviewer |
program.twins.read_own | R3 | Read your own twins, duties and schedules | every program persona |
program.twins.use | R3 | Start, pause, resume, configure or retire your own twin and approve its standing plans | every program persona but viewer |
program.twins.manage | R3 | Pause or retire any twin and set program twin defaults, with a reason | program_owner, program_lead |
program.handoffs.read | R3 | Read hand-off requests | every program persona |
program.handoffs.manage | R3 | Send, accept, decline, complete or withdraw hand-off requests | every program persona but viewer |
review.stage_reviews.approve | R5 | Sign the quality review of a stage gate | quality_reviewer, program_lead, program_owner |
progress.stages.read | R5 | Read stage gates | every program persona |
progress.stages.manage | R5 | Open a stage gate and mark it ready | program_owner, program_lead, manufacturing_specialist |
progress.stages.approve | R5 | Close a stage gate with a signature | program_owner, program_lead |
org.twins.use | R6 | Run an organisation twin | org_owner, org_admin |
Group counts at R3: org 13, program 27, evidence 13, plans 14, review 10, progress 10; sync line
permissions in sync: 87 strings (13 org, 74 program) across 2 catalogs; unchanged at R4. R5: review 11,
progress 13, org 15 (93 strings (15 org, 78 program)); R6 org 16. No dormant key is created: org.twins.use
ships with its routes in R6. program.settings.update is re-described in R5 as “Update program settings” because the gate
becomes the only writer of the stage. The full catalog is on the permissions page.
Personas per version
| Persona | v2 | v3 (R3, in code) | v4 (R5) | Change at v3 |
|---|---|---|---|---|
program_owner | 68 | 74 | 78 | absorbs every new key |
program_lead | 67 | 73 | 77 | absorbs every new key |
scientist | 47 | 49 | 50 | gains twins.read_own, twins.use, handoffs.read, handoffs.manage; loses evidence.hypotheses.approve and plans.plans.approve; v4 (R5) gains progress.stages.read |
quality_reviewer | 38 | 43 | 46 | gains program.twins.read (oversight), read_own, use, both hand-off keys; v4 (R5) gains progress.stages.read, review.stage_reviews.approve and program.agent_memory.manage (contract 12.6) |
manufacturing_specialist | 34 | 38 | 41 | gains read_own, use, both hand-off keys; v4 (R5) gains progress.stages.read, progress.stages.manage and program.agent_memory.manage (contract 12.6) |
viewer | 27 | 29 | 30 | gains read_own and handoffs.read only; has no twin |
TWIN_OVERSIGHT (program.twins.read) is subtracted from scientist, manufacturing specialist and viewer the
way THREAD_OVERSIGHT is; TWIN_PARTICIPANT (program.twins.read_own, program.twins.use,
program.handoffs.manage) is added to every program persona but the viewer; program.handoffs.read is
absorbed by the program-scope reads; SCIENTIST_APPROVALS_WITHDRAWN names the two approve keys. Organisation
personas are unchanged until R6 (org_owner and org_admin + 1; R5 adds org.role_memory.read | manage to the organisation personas, contract 12.6). The sizes are asserted in
docs/lib/content.ts (PERSONA_BUNDLE_SIZES) against the copied bundles, and the
personas page renders the matrix.
Kinds
| Enumeration | Release | Values added | Count |
|---|---|---|---|
AGENT_RUN_KINDS | R3 | twin_cycle, twin_handoff | 11 -> 13 |
AGENT_RUN_KINDS | R5 | stage_readiness | 13 -> 14 |
agent_runs.initiated_by (RUN_INITIATORS) | R3 | schedule, event, handoff, user (null for non-twin runs) | new |
INTERRUPT_KINDS | R3 | batch_approval (at most 10 items of one tool and target type, explicit selection, per-item permission and audit) | 4 -> 5 |
run_interrupts.detached | R3 | true for file-and-finish proposals whose run does not wait | new column |
inbox_items.kind (INBOX_ITEM_KINDS) | R3 | decision, signature, question, handoff, mention, information | new |
inbox_items.kind | R5 | stage | + 1 |
inbox_items.status (INBOX_ITEM_STATUSES) | R3 | open, snoozed, done, dismissed, expired | new |
inbox_items.feedback (INBOX_FEEDBACK) | R3 | none, helpful, not_useful | new |
twin_definitions.status (TWIN_STATUSES) | R3 | active, paused, retired | new |
twin_definitions.paused_reason (TWIN_PAUSED_REASONS) | R3 | user, manage, budget, high_decline_rate, permission_lost, membership_lost, user_inactive, migrated | new |
twin_triggers.kind (TWIN_TRIGGER_KINDS) | R3 | schedule, event, handoff, mention | new |
twin_triggers.priority (TWIN_TRIGGER_PRIORITIES) | R3 | now, digest | new |
handoff_requests.kind (HANDOFF_KINDS) | R3 | review, data, decision, information | new |
handoff_requests.status (HANDOFF_STATUSES) | R3 | sent, accepted, declined, done, withdrawn (no unreachable proposed state) | new |
delegations.status (DELEGATION_STATUSES) | R3 | active, expired, revoked | new |
NOTIFICATION_KINDS | R3 | handoff_received, digest, twin_paused, mention, decision_reminder (e-mailed by design: handoff_received, digest; the e-mail sweep still selects the two phase-2 kinds until the twins service lands) | 7 -> 12 |
NOTIFICATION_KINDS | R5 | stage_ready | 12 -> 13 |
ARTIFACT_KINDS | R3 | persona_brief (server-written, no model call) | 10 -> 11 |
ARTIFACT_KINDS | R5 | readiness_brief | 12 -> 13 (R4 adds skill_result, 11 -> 12) |
stage_gates.status | R5 | open, ready, reviewed, closed | new |
SIGNATURE_TARGET_TYPES, APPROVAL_TARGET_TYPES | R5 | stage_gate | 3 -> 4, 5 -> 6 |
| Tone map | R3 | enabled (pass); sent (review); done, snoozed, revoked (neutral): 12 pass, 16 review, 7 fail, 4 info, 22 neutral | 56 -> 61 |
| Tone map | R5 | reviewed (pass) | 61 -> 62 |
Every enumeration is a text column with a named CHECK, as everywhere else (statuses).
Interrupts: detached, bound and batched
- Detached (
run_interrupts.detached = true): the interrupt is inserted with itstool_callsrows (proposed,proposal_hash,basis_hash) without touchingrun.status; the container receives an acknowledgement and continues, and atwin_cycleendssucceededafter filing everything it has. Approval later executes the write server-side from the stored arguments throughapp/tools/handlers.pyunder the resolver’s principal; the agent audit row carriesinterrupt_id,approved_by_user_id, both hashes,twin_idandtrigger_id. Expiry writes nothing, marks the item “Expired, nothing written” and leaves the runsucceeded;expire_pendingfails only attached runs. Console turns stay attached and behave as before. - Bound-user rule: for any interrupt on a run with
twin_id, the twin is checked before the thread rule.write_approvalandbatch_approvalare resolvable by the bound person or an active delegate who holds the item’s write permission;plan_approvalby the bound person only; everyone else, including holders ofprogram.threads.readandprogram.threads.use, gets403 bound_user_onlynaming the twin. A standing-plan approval recordspermission_exercised = program.twins.use. - Stale proposals: every
propose_*tool computesbasis_hashover{target_type, target_id, updated_at, status}of the linked record; at approval the service recomputes it and refuses a mismatch with409 stale_proposalnaming the changed fields; the card reads “Changed since proposed”; the proposal staysproposedand nothing is written. - Batches (
batch_approval): one interrupt bundles up to 10 write proposals of the same tool and target type under onebatch_key; one open batch per run.InterruptResolve.items[](1 to 10) is required for a batch and refused (422) otherwise; a top-leveldecisionon a batch is refused. The service mints onebatch_idper call and, for each item in its own savepoint, checks the resolver holds that item’sToolSpec.permission(403 batch_item_forbidden, itemfailed), verifiesbasis_hash, requires a reason on decline, applies edits, recomputesapproved_hash, executes the write (detached) or records the decision for resume (attached), and writes oneinterrupt.resolveaudit row per item withinterrupt_idandbatch_id. Approved items are written on the call that approves them; undecided items stayproposeduntil expiry, when they are markedexpiredone by one and the interrupt closes withresolution.partial = trueand the counts on the card (“5 approved and written, 5 expired”). The response isInterruptResolveResult {batch_id, items[{tool_call_id, status, code?}]}.
Tools
All pass the forbidden-verb test of the catalogue (FORBIDDEN_TOOL_VERBS: approve, request_approval, sign,
signature, notify, send_mail); every propose_* handler computes basis_hash.
| Tool | Release | Kind | Permission exercised |
|---|---|---|---|
console.propose_handoff | R3 | write (never in PLANNING_TOOLS) | program.handoffs.manage |
console.list_handoffs | R3 | read | program.handoffs.read |
console.get_twin | R3 | read | program.twins.read_own |
console.list_decisions_waiting | R3 | read | program.twins.read_own |
plans.propose_plan_change | R3 | write | plans.plans.manage |
console.get_stage_gate | R5 | read | progress.stages.read |
progress.list_stage_gates | R5 | read | progress.stages.read |
Seeded capabilities (R3): console.twin_literature_watch, console.twin_handoff, review.twin_check_triage,
each with a context of use, limitations and a risk class.
Worker jobs, triggers and registries
| Job | Release | Interval | Does |
|---|---|---|---|
twin_schedule | R3 | 60 s | claims due twin_schedules, enqueues twin_cycle with initiated_by = schedule, honours backpressure and the cadence floor |
twin_events_reconcile | R3 | 3600 s | compares the audit entry_seq watermark per trigger and enqueues anything missed or marked by backpressure |
twin_digest | R3 | 60 s | writes the persona_brief artifact, then the digest notification with deliver_after, in that order |
inbox_expire | R3 | 3600 s | expires items (“Expired, nothing written”, the run stays succeeded), un-snoozes, raises decision_reminder at most daily per item |
twin_evaluate | R3 | 86400 s | proposals filed, approved unchanged, edited, declined by reason, refused, expired; not_useful rate per trigger |
stage_readiness_refresh | R5 | 3600 s | recomputes required_records for open gates only |
TRIGGERABLE_ACTIONS (closed, api/app/db/models/twin.py): connector_object.update, hypothesis.decision,
sop_version.update, milestone.update, handoff.accept, experiment.update, approval.create.
PAUSE_ACTIONS: role_assignment.delete, user.deactivate. AUDIT_ACTIONS (api/app/services/audit_actions.py)
registers every action name write_audit accepts and refuses an unregistered name under test; the post-flush
hook runs only for TRIGGERABLE_ACTIONS and PAUSE_ACTIONS, inside a savepoint so a hook failure never fails
the record write, never re-enters, coalesces per trigger and interval, and inserts twin_cycle runs with
on_conflict_do_nothing on (trigger_id, target_id). New audited mutations: inbox.snooze, inbox.dismiss,
inbox.feedback, twin.create, twin.update, twin.pause, twin.resume, twin.retire,
twin.duty_enable, twin.duty_disable, twin.self_pause, twin.enqueue, handoff.send, handoff.accept,
handoff.decline, handoff.complete, handoff.withdraw, delegation.create, delegation.renew,
delegation.expire, delegation.revoke, mention.create; R5 stage_gate.open, stage_gate.ready,
stage_gate.reopen, stage_gate.review, stage_gate.close, stage_gate.supersede.
Routes
P is /programs/{program_id}. Counts: 157 after the site wave, 189 at R3 (+32, listed in
api/scripts/designed-routes.json), 206 at R4 (+17, the workbench routes of contract 12.7), 223 at R5 (+17: stage gates 7, lens reviews 3, role memory 6, propose-share 1), 228 at R6 (+5, the two organisation-twin routes among them). Router order: inbox after
me_settings; twins, handoffs, board, metrics after progress; the export in audit_export.
make routes-check prints check_routes: 189 designed, 0 missing once the R3 routers are mounted.
| Group | Release | Routes |
|---|---|---|
| Inbox and brief (9) | R3 | GET /me/inbox; GET /me/inbox/{item_id}; POST /me/inbox/{item_id}/resolve (the path the inbox, the e-mail link and the phone use; authorised by item ownership or an active delegation, never by reading the private thread); POST .../snooze; POST .../feedback; GET /me/brief; GET, POST /me/delegations; DELETE /me/delegations/{delegation_id} |
| Twins (13) | R3 | GET, POST P/twins; GET, PATCH P/twins/{twin_id}; POST .../pause, .../resume, .../run-now; GET, POST P/twins/{twin_id}/triggers; PATCH, DELETE .../triggers/{trigger_id}; GET, PUT .../schedule |
| Hand-offs (7) | R3 | GET, POST P/handoffs; GET P/handoffs/{handoff_id}; POST .../accept, .../decline, .../complete, .../withdraw, each scoped by party |
| Board, metrics, export (3) | R3 | GET P/board; GET P/metrics/decisions (per-persona aggregates under program.audit.read; by_user[] only under program.twins.read); GET /orgs/current/twins/export (org.audit.read, metadata only, never tool_calls.arguments) |
| Stage gates (7) | R5 | GET, POST P/progress/stage-gates; GET .../{gate_id}; POST .../ready; POST .../review (signature); POST .../close (signature and approval); GET .../brief |
| Organisation twins (2) | R6 | GET, POST /orgs/current/twins |
Error codes added: R3 stale_proposal, handoff_exists, self_handoff, twin_paused,
twin_active_exists, duty_unavailable, delegation_invalid, batch_item_forbidden, bound_user_only,
baseline_missing; R5 gate_incomplete, gate_stage_restricted, reviewer_role_required, same_signer,
stage_via_gate. Every route is listed with its rule on the API page.
Fixed phrases
Console copy (web/lib/content.ts consoleCopy, mirrored by docs/lib/content.ts), quoted by the web app,
the docs and the deck and never paraphrased; angle-bracket parts are filled at render time.
| Phrase | Release | Where |
|---|---|---|
Decisions waiting for you | R3 | inbox heading, program home |
Your twin proposes. You decide. | R3 | twin setup, twin panel |
Your twin never approves, signs or sends. | R3 | twin setup, twin panel |
Paused. Nothing runs until you resume. | R3 | twin panel when paused |
Nothing waiting. Your twin's next pass is at <time>. | R3 | inbox empty state |
Expired, nothing written | R3 | expired proposal card |
Changed since proposed | R3 | proposal refused with stale_proposal |
Filing paused: <N> decisions waiting | R3 | brief under backpressure |
Let my twin look at this | R3 | mention item; the only way a mention starts a run |
Share this thread to mention colleagues. | R3 | composer in a private thread |
Declining needs a reason; it is stored as evaluation data. | R3 | the inline decline form in the item (contract 12.10) |
Proposed by <Name>'s twin (agent); decided by <Name> | R3 | record footer |
Routed under delegation from <Name>; decided by <Name> in their own right. | R3 | delegated decision |
Agent-generated draft, unsigned. | phase 2 | every twin-written artifact (existing phrase) |
Viewers have no twin. Nothing waits for you. | R3 | viewer home |
Every decision stays human and recorded. Timing claims wait for measured data. | phase 3 | interim copy on site, docs and deck |
<N> of <M> required records present | R5 | stage gate card |
Stage gate reviewed by <Name> (reviewed) and closed by <Name> (approved) | R5 | closed gate footer |
Twin run footers read “Started by schedule, event, hand-off or you under Name’s standing plan (approved
date, plan hash prefix)” and never “Requested by”. The word is “agent-generated”; nothing is described as
generated by a model or a vendor. Inbox groups in display order: Signatures, Approvals, Questions, Hand-offs,
Mentions, Information. Board columns: “Waiting on scientists”, “Waiting on QA”, “Waiting on the lead”,
“Waiting on the owner”, “Waiting on manufacturing”. Console routes: /me/inbox, /me/inbox/[itemId],
/me/brief, /me/delegations, /p/[slug]/board, /p/[slug]/twin, /p/[slug]/handoffs/[id].
Source: PRODUCT-CONTRACT.md sections 11.4 and 11.7; PRODUCT-PLAN.md Part 7 sections 5, 6 and 7 (7.1 to 7.5); api/app/auth/permissions.py (EXPECTED_PERMISSION_COUNT = 87); api/app/auth/personas.py (PERSONA_VERSION = 3); api/scripts/designed-routes.json (189); api/alembic/versions/0004_chain.py; api/alembic/versions/0005_twins.py; api/tests/test_migrations.py (HEAD = "0005", EXPECTED_TABLE_COUNT = 60); api/app/db/models/twin.py; api/app/db/models/inbox.py; api/app/db/models/handoff.py; api/app/db/models/delegation.py; api/app/db/models/agent.py; api/app/db/models/conversation.py; api/app/services/audit_actions.py; api/app/services/interrupts.py; api/app/services/notifications.py (EMAILED_KINDS); api/app/tools/catalogue.py (FORBIDDEN_TOOL_VERBS); api/app/db/models/signatures.py; api/app/db/models/progress.py; web/lib/api/types.ts; web/lib/content.ts; web/lib/status.ts (TONE_STATUS_COUNT = 61); docs/lib/content.ts