Skip to Content

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

TableReleasePurposeKey columns
audit_chainR3 (0004_chain, table only)the hash chain over audit_events; the worker job chain_append stays disabled until R2’s chain work landsappend-only, insert and read only for the application role
twin_definitionsR3one twin per persona, person and programorganization_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_triggersR3a duty’s schedule, event, hand-off or mention triggertwin_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_schedulesR3next run per triggertwin_id, trigger_id, rrule, next_run_at, last_run_at, locked_at
inbox_itemsR3transactional pointers to what waits for a personuser_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_requestsR3the only channel between twins, created sent on approvalfrom_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
delegationsR3one-hop routing of write approvalsuser_id, delegate_user_id (must differ), program_id (nullable), starts_at, ends_at (at most 30 days), reason, renewal_count, revoked_at, status
stage_gatesR5the only writer of programs.current_stage_idprogram_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_reviewsR5QA’s monthly oversight summary, counts and ids onlyprogram_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).

KeyReleaseDescriptionHeld by (persona)
program.twins.readR3Read every twin of the program and its activity, for oversightprogram_owner, program_lead, quality_reviewer
program.twins.read_ownR3Read your own twins, duties and schedulesevery program persona
program.twins.useR3Start, pause, resume, configure or retire your own twin and approve its standing plansevery program persona but viewer
program.twins.manageR3Pause or retire any twin and set program twin defaults, with a reasonprogram_owner, program_lead
program.handoffs.readR3Read hand-off requestsevery program persona
program.handoffs.manageR3Send, accept, decline, complete or withdraw hand-off requestsevery program persona but viewer
review.stage_reviews.approveR5Sign the quality review of a stage gatequality_reviewer, program_lead, program_owner
progress.stages.readR5Read stage gatesevery program persona
progress.stages.manageR5Open a stage gate and mark it readyprogram_owner, program_lead, manufacturing_specialist
progress.stages.approveR5Close a stage gate with a signatureprogram_owner, program_lead
org.twins.useR6Run an organisation twinorg_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

Personav2v3 (R3, in code)v4 (R5)Change at v3
program_owner687478absorbs every new key
program_lead677377absorbs every new key
scientist474950gains twins.read_own, twins.use, handoffs.read, handoffs.manage; loses evidence.hypotheses.approve and plans.plans.approve; v4 (R5) gains progress.stages.read
quality_reviewer384346gains 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_specialist343841gains read_own, use, both hand-off keys; v4 (R5) gains progress.stages.read, progress.stages.manage and program.agent_memory.manage (contract 12.6)
viewer272930gains 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

EnumerationReleaseValues addedCount
AGENT_RUN_KINDSR3twin_cycle, twin_handoff11 -> 13
AGENT_RUN_KINDSR5stage_readiness13 -> 14
agent_runs.initiated_by (RUN_INITIATORS)R3schedule, event, handoff, user (null for non-twin runs)new
INTERRUPT_KINDSR3batch_approval (at most 10 items of one tool and target type, explicit selection, per-item permission and audit)4 -> 5
run_interrupts.detachedR3true for file-and-finish proposals whose run does not waitnew column
inbox_items.kind (INBOX_ITEM_KINDS)R3decision, signature, question, handoff, mention, informationnew
inbox_items.kindR5stage+ 1
inbox_items.status (INBOX_ITEM_STATUSES)R3open, snoozed, done, dismissed, expirednew
inbox_items.feedback (INBOX_FEEDBACK)R3none, helpful, not_usefulnew
twin_definitions.status (TWIN_STATUSES)R3active, paused, retirednew
twin_definitions.paused_reason (TWIN_PAUSED_REASONS)R3user, manage, budget, high_decline_rate, permission_lost, membership_lost, user_inactive, migratednew
twin_triggers.kind (TWIN_TRIGGER_KINDS)R3schedule, event, handoff, mentionnew
twin_triggers.priority (TWIN_TRIGGER_PRIORITIES)R3now, digestnew
handoff_requests.kind (HANDOFF_KINDS)R3review, data, decision, informationnew
handoff_requests.status (HANDOFF_STATUSES)R3sent, accepted, declined, done, withdrawn (no unreachable proposed state)new
delegations.status (DELEGATION_STATUSES)R3active, expired, revokednew
NOTIFICATION_KINDSR3handoff_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_KINDSR5stage_ready12 -> 13
ARTIFACT_KINDSR3persona_brief (server-written, no model call)10 -> 11
ARTIFACT_KINDSR5readiness_brief12 -> 13 (R4 adds skill_result, 11 -> 12)
stage_gates.statusR5open, ready, reviewed, closednew
SIGNATURE_TARGET_TYPES, APPROVAL_TARGET_TYPESR5stage_gate3 -> 4, 5 -> 6
Tone mapR3enabled (pass); sent (review); done, snoozed, revoked (neutral): 12 pass, 16 review, 7 fail, 4 info, 22 neutral56 -> 61
Tone mapR5reviewed (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 its tool_calls rows (proposed, proposal_hash, basis_hash) without touching run.status; the container receives an acknowledgement and continues, and a twin_cycle ends succeeded after filing everything it has. Approval later executes the write server-side from the stored arguments through app/tools/handlers.py under the resolver’s principal; the agent audit row carries interrupt_id, approved_by_user_id, both hashes, twin_id and trigger_id. Expiry writes nothing, marks the item “Expired, nothing written” and leaves the run succeeded; expire_pending fails 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_approval and batch_approval are resolvable by the bound person or an active delegate who holds the item’s write permission; plan_approval by the bound person only; everyone else, including holders of program.threads.read and program.threads.use, gets 403 bound_user_only naming the twin. A standing-plan approval records permission_exercised = program.twins.use.
  • Stale proposals: every propose_* tool computes basis_hash over {target_type, target_id, updated_at, status} of the linked record; at approval the service recomputes it and refuses a mismatch with 409 stale_proposal naming the changed fields; the card reads “Changed since proposed”; the proposal stays proposed and nothing is written.
  • Batches (batch_approval): one interrupt bundles up to 10 write proposals of the same tool and target type under one batch_key; one open batch per run. InterruptResolve.items[] (1 to 10) is required for a batch and refused (422) otherwise; a top-level decision on a batch is refused. The service mints one batch_id per call and, for each item in its own savepoint, checks the resolver holds that item’s ToolSpec.permission (403 batch_item_forbidden, item failed), verifies basis_hash, requires a reason on decline, applies edits, recomputes approved_hash, executes the write (detached) or records the decision for resume (attached), and writes one interrupt.resolve audit row per item with interrupt_id and batch_id. Approved items are written on the call that approves them; undecided items stay proposed until expiry, when they are marked expired one by one and the interrupt closes with resolution.partial = true and the counts on the card (“5 approved and written, 5 expired”). The response is InterruptResolveResult {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.

ToolReleaseKindPermission exercised
console.propose_handoffR3write (never in PLANNING_TOOLS)program.handoffs.manage
console.list_handoffsR3readprogram.handoffs.read
console.get_twinR3readprogram.twins.read_own
console.list_decisions_waitingR3readprogram.twins.read_own
plans.propose_plan_changeR3writeplans.plans.manage
console.get_stage_gateR5readprogress.stages.read
progress.list_stage_gatesR5readprogress.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

JobReleaseIntervalDoes
twin_scheduleR360 sclaims due twin_schedules, enqueues twin_cycle with initiated_by = schedule, honours backpressure and the cadence floor
twin_events_reconcileR33600 scompares the audit entry_seq watermark per trigger and enqueues anything missed or marked by backpressure
twin_digestR360 swrites the persona_brief artifact, then the digest notification with deliver_after, in that order
inbox_expireR33600 sexpires items (“Expired, nothing written”, the run stays succeeded), un-snoozes, raises decision_reminder at most daily per item
twin_evaluateR386400 sproposals filed, approved unchanged, edited, declined by reason, refused, expired; not_useful rate per trigger
stage_readiness_refreshR53600 srecomputes 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.

GroupReleaseRoutes
Inbox and brief (9)R3GET /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)R3GET, 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)R3GET, POST P/handoffs; GET P/handoffs/{handoff_id}; POST .../accept, .../decline, .../complete, .../withdraw, each scoped by party
Board, metrics, export (3)R3GET 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)R5GET, POST P/progress/stage-gates; GET .../{gate_id}; POST .../ready; POST .../review (signature); POST .../close (signature and approval); GET .../brief
Organisation twins (2)R6GET, 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.

PhraseReleaseWhere
Decisions waiting for youR3inbox heading, program home
Your twin proposes. You decide.R3twin setup, twin panel
Your twin never approves, signs or sends.R3twin setup, twin panel
Paused. Nothing runs until you resume.R3twin panel when paused
Nothing waiting. Your twin's next pass is at <time>.R3inbox empty state
Expired, nothing writtenR3expired proposal card
Changed since proposedR3proposal refused with stale_proposal
Filing paused: <N> decisions waitingR3brief under backpressure
Let my twin look at thisR3mention item; the only way a mention starts a run
Share this thread to mention colleagues.R3composer in a private thread
Declining needs a reason; it is stored as evaluation data.R3the inline decline form in the item (contract 12.10)
Proposed by <Name>'s twin (agent); decided by <Name>R3record footer
Routed under delegation from <Name>; decided by <Name> in their own right.R3delegated decision
Agent-generated draft, unsigned.phase 2every twin-written artifact (existing phrase)
Viewers have no twin. Nothing waits for you.R3viewer home
Every decision stays human and recorded. Timing claims wait for measured data.phase 3interim copy on site, docs and deck
<N> of <M> required records presentR5stage gate card
Stage gate reviewed by <Name> (reviewed) and closed by <Name> (approved)R5closed 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

Last updated on