docs: publish reviewed CHAT-01 draft contracts and fixtures (#1507)
This commit is contained in:
@@ -0,0 +1,386 @@
|
|||||||
|
# CHAT-01 draft conversation and control contracts
|
||||||
|
|
||||||
|
Issue #1507, authorized charter 26103. Darkwing authors; Filbert independently
|
||||||
|
reviews; Dewey reviews UI semantics; Rocko reviews architecture. Research baseline
|
||||||
|
is `370823b354400a73425b4180e07747d97ff87e7a`.
|
||||||
|
|
||||||
|
R3 draft, superseding rejected R1 request 26105 and R2 request 26117. R2
|
||||||
|
findings are Filbert 26119, Dewey 26120 and Rocko's adversarial re-review via
|
||||||
|
agent-send. All three accept the named companion gates below. Approval must name
|
||||||
|
all four current file hashes; prior reviews do not approve changed bytes.
|
||||||
|
|
||||||
|
These are proposed contracts and synthetic reference models. They implement no
|
||||||
|
endpoint, engine adapter, permission grant or live migration. Version 2 is a draft
|
||||||
|
revision, not a migration of an existing Mosaic record. Q21 publication does not
|
||||||
|
authorize deploying it. Only this directory's four files and scoped tracking are
|
||||||
|
in scope. Strict foundation isolation remains; no host-seat exception is approved.
|
||||||
|
|
||||||
|
## Record and trust boundary
|
||||||
|
|
||||||
|
The local-only draft 2020-12 schema has 24 closed record variants and 20 commands.
|
||||||
|
Every declared property is required. Null means absent or unknown, not permission.
|
||||||
|
IDs are opaque references, never filesystem paths, bearer credentials or proof.
|
||||||
|
UTC dates receive calendar validation. Integers fit JavaScript's safe range.
|
||||||
|
The example.invalid schema ID requires no network access.
|
||||||
|
|
||||||
|
Only `clientRequest` is untrusted client input. Its actor, authenticated channel,
|
||||||
|
grant, policy and supervisor facts come from authoritative server state, never
|
||||||
|
from a request header, posted record or tmux preamble. Other variants describe
|
||||||
|
server records or authorized projections. Shape validity authenticates nothing.
|
||||||
|
Never expose binding, grant, proof records or private blob storage wholesale.
|
||||||
|
Text, filenames, Markdown, arguments and labels remain untrusted render data.
|
||||||
|
|
||||||
|
| Records | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| binding, grant, connection | Approved exact scope, source/config/engine pins, execution/cohort, current generation and fence; independent actor authority; authenticated connection incarnation |
|
||||||
|
| clientRequest, request, receipt | Expected target and command; immutable causal admission; monotonically revised actual or uncertain outcome |
|
||||||
|
| catalogueItem, clientView | Authorized catalogue plus safe current target, connection, view revision, observer/controller mode, supported operations/reasons and attachment limits |
|
||||||
|
| entry, event, cursor, page | Stable history/message/block fragments, ordered stream, actor-bound opaque cursor, exact snapshot and stream watermark |
|
||||||
|
| draft, upload, draftListing | Actor-private composer revision, private staged bytes and bounded private listing; no native delivery before Send |
|
||||||
|
| frozenPayload, queueItem | Immutable accepted text/upload revisions and digest; queue revision, admission lineage, original actor and dispatch disposition |
|
||||||
|
| nativeDecision, approval | Stable native decision identity and generation-specific UI projection with exact intent display, native choices and causal decider |
|
||||||
|
| confirmation | Short-lived single-use actor/connection-incarnation/target/operation/intent confirmation |
|
||||||
|
| stop, cohortProof, effectReport, turnProof | Recorded stop transitions, selected-cohort observations, explicit effect dispositions and native-turn reconciliation |
|
||||||
|
|
||||||
|
Capabilities in this proposal are names for future reviewed grants, not changes
|
||||||
|
to `roles/`. Missing grants, mappings, evidence, supported operations or required
|
||||||
|
recording capacity refuse effects. Do not turn a fail-closed refusal into a
|
||||||
|
fallback tmux paste, CLI launch or guessed source path.
|
||||||
|
|
||||||
|
## Safe bootstrap, history and rendering
|
||||||
|
|
||||||
|
An authorized catalogue response includes `currentView`. It supplies the target
|
||||||
|
needed for a first observe/takeover request without exposing the private binding.
|
||||||
|
View revisions increase on refresh. Transfer, revocation and reconciliation require
|
||||||
|
a fresh view. Availability intersects current grants, verified adapter support and
|
||||||
|
control/fence state; request-specific prerequisites still apply. Disabled controls
|
||||||
|
carry reasons. A stale view never authorizes a command. Recheck authoritative state
|
||||||
|
at admission and dispatch, including stricter verified byte limits.
|
||||||
|
|
||||||
|
All connections start as observers. Catalogue creation time, engine launch time
|
||||||
|
and last activity are separate nullable values. Relative Age uses last activity.
|
||||||
|
Earlier conversations and branches are read-only. Opening them never resumes,
|
||||||
|
forks or launches. Each conversation needs an approved project/workspace mapping;
|
||||||
|
OS readability, cwd and seat labels do not establish membership.
|
||||||
|
|
||||||
|
Cursors bind actor, purpose, conversation, branch, snapshot, source epoch and
|
||||||
|
expiry. Refuse unknown, foreign, expired or source-replaced cursors and retain the
|
||||||
|
old view with a reconciliation indicator. Never silently switch to a newer file.
|
||||||
|
History pages contain at most 100 parts and must also fit 8 MiB serialized UTF-8.
|
||||||
|
Each part has at most 64 blocks; strings cap at 262144 Unicode characters.
|
||||||
|
Split into continuation parts instead of clipping. Byte enforcement and renderer
|
||||||
|
safety need separate implementation tests; schema character limits are insufficient.
|
||||||
|
|
||||||
|
Render Markdown/code/tool details and permitted reasoning without active HTML,
|
||||||
|
unsafe URLs or terminal escape execution. Unavailable reasoning has empty text;
|
||||||
|
unavailable events have empty content. The schema rejects hidden payloads under
|
||||||
|
an unavailable label. This does not prove renderer safety or source redaction.
|
||||||
|
|
||||||
|
### History and stream identity
|
||||||
|
|
||||||
|
Every content event carries an explicit bounded role, including message-start
|
||||||
|
and final parts. Role comes from authorized native message metadata, never a
|
||||||
|
request ID or guessed text. Entry and stream roles must agree for the same stable
|
||||||
|
message; conflicting role or persisted-entry attribution requires reconciliation.
|
||||||
|
A final event is self-describing even when no history row preceded it. Empty-history
|
||||||
|
NEW user then assistant output, null causal request and reconnect overlap are tested.
|
||||||
|
|
||||||
|
`entry.message` and `event.message` are stable within the execution. A broker may
|
||||||
|
allocate a transient message key before native persistence, but must preserve it
|
||||||
|
when attaching a persisted entry ID. Unknown causal requests stay null. A content
|
||||||
|
event cannot omit its message key merely because request and entry are unknown.
|
||||||
|
|
||||||
|
Every block carries `block`, `fragment`, `lastFragment`. Block ordinals start at
|
||||||
|
zero per message; fragments start at zero per block and stay contiguous across
|
||||||
|
entry parts. Continuations retain type and tool-call identity. Do not render a
|
||||||
|
split tool invocation as several independent calls.
|
||||||
|
|
||||||
|
Final message events use `updateMode:replace`, exact entry/message, `part` and
|
||||||
|
`lastPart`. They replace only that addressed part, not the entire message. Text
|
||||||
|
and reasoning deltas append to the addressed block fragment. Tool updates replace
|
||||||
|
the addressed fragment. Once authoritative final parts arrive, replace provisional
|
||||||
|
content rather than appending it again. Missing parts/fragments are incomplete,
|
||||||
|
never silently dropped. Preserve branch and subagent separation.
|
||||||
|
|
||||||
|
A page's `streamEpoch` and `throughSequence` describe an atomic snapshot cut.
|
||||||
|
Subscribe/replay after that watermark. Deduplicate overlap; repeated event IDs
|
||||||
|
must have identical bytes. A final-part overlap conflicting with the snapshot,
|
||||||
|
sequence gap, unavailable replay or new epoch requires reconciliation. Do not
|
||||||
|
concatenate across a gap. The source adapter must produce this atomic cut or
|
||||||
|
advertise streaming as unavailable. R3 checks multipart finals, two blocks, null
|
||||||
|
request correlation, duplicate delivery, page overlap and reconnect gap/epoch
|
||||||
|
refusal. It does not prove a native stream producer or every delta subtype.
|
||||||
|
|
||||||
|
Keep loaded history, selection/caret/scroll and private drafts when paused or
|
||||||
|
offline. Show stale/paused state and reconcile on reconnect. Native acknowledgment
|
||||||
|
is not an assistant answer. Actual return-flow acceptance still requires sending
|
||||||
|
and receiving NEW assistant/tool text in the selected UI without manual refresh.
|
||||||
|
|
||||||
|
## Admission, dispatch and deduplication
|
||||||
|
|
||||||
|
Resolve authenticated connection and actor, current grant revision/expiry/scope,
|
||||||
|
approved mapping, verified command support, configuration/trust/policy and evidence
|
||||||
|
capacity. Browser Origin/Host/CSRF and private peer authentication remain required
|
||||||
|
runtime gates. Nothing in these files implements authentication.
|
||||||
|
|
||||||
|
Commands assert conversation/branch/execution/generation; those assertions never
|
||||||
|
choose a host process or storage path. Private drafts/uploads need their own
|
||||||
|
actor-scoped grants, not existing input control. Observe can read an authorized
|
||||||
|
history branch without acquiring control. Native work needs current control,
|
||||||
|
except independent takeover and recovery-control acquisition described below.
|
||||||
|
|
||||||
|
Commit intent, frozen bytes and queue transition before reporting admission.
|
||||||
|
The future broker must serialize dispatch, transfer, edits and cancellation.
|
||||||
|
Several file renames are not a proven multi-record transaction.
|
||||||
|
|
||||||
|
Canonical fixture hashing uses UTF-8 JSON, lexically sorted object keys, preserved
|
||||||
|
array order and no whitespace. Production decoding must reject duplicate keys,
|
||||||
|
unpaired surrogates, non-finite numbers and unsafe integers. No text normalization
|
||||||
|
or claim of full RFC 8785 conformance is made.
|
||||||
|
|
||||||
|
The client fingerprint covers target and validated command, excluding transport
|
||||||
|
connection and client request ID. The durable prompt/edit `operationDigest` also
|
||||||
|
covers the frozen payload digest, including text and complete upload revisions.
|
||||||
|
Dedup keys are authenticated actor, approved conversation and client request ID.
|
||||||
|
An exact reconnect retry returns the existing receipt, without requiring that the
|
||||||
|
new connection is controller and without redispatch. Conflicting content refuses.
|
||||||
|
Resolve referenced revisions against the original immutable admission on retry,
|
||||||
|
not the latest mutable draft. Delivery-unknown is never permission to replay.
|
||||||
|
The checker models a single receipt lookup and derives its current queue outcome
|
||||||
|
when available, including recovery after takeover. It does not implement a durable
|
||||||
|
multi-request index or monotonically persisted receipt revisions.
|
||||||
|
|
||||||
|
Dispatch resolves queueItem and its stored admission, then rechecks CURRENT
|
||||||
|
grant/policy/target/controller/fence and the frozen digest/revision. New client
|
||||||
|
commands require a connected authenticated channel. The scheduler instead uses
|
||||||
|
the authenticated durable admission and retained execution/controller claim;
|
||||||
|
ordinary browser disconnect alone does not pause already broker-acknowledged
|
||||||
|
queued work. Broker acknowledgment means durable admission, not native acceptance.
|
||||||
|
Native acknowledgment occurs later and never grants replay. Revocation, grant
|
||||||
|
expiry, generation change or fences still refuse dispatch.
|
||||||
|
|
||||||
|
A pre-dispatch refusal moves a still-queued item to `dispatch-refused`, stores
|
||||||
|
failureReason and emits queue-changed. It is not eligible for another scheduler
|
||||||
|
attempt. Existing recovered/terminal dispositions are retained. Reconciliation
|
||||||
|
returns that actual state, not a stale queued receipt. Frozen content remains
|
||||||
|
preserved; no automatic re-admission or native replay is inferred.
|
||||||
|
Missing or altered frozen content refuses. A later mutable source-draft revision
|
||||||
|
is orthogonal and does not invalidate an unchanged admitted snapshot. An edit
|
||||||
|
creates a new immutable admission and queue revision; original admission and
|
||||||
|
payload stay unchanged. Per-item cancel affects only that item. Bulk recovery
|
||||||
|
classifies all selected snapshots before committing dispositions. Valid content
|
||||||
|
becomes recovered drafts; missing/corrupt content becomes `recovery-failed` with
|
||||||
|
failureReason and queue-changed evidence. No text is invented from damaged bytes.
|
||||||
|
Control transfer and cancellation must still succeed. Stop records list successful
|
||||||
|
queueDrafts separately from queueFailures. These failed items preserve their
|
||||||
|
references for authorized repair/readback, never automatic resend.
|
||||||
|
|
||||||
|
## Commands and private composer lifecycle
|
||||||
|
|
||||||
|
All twenty commands are closed unions; no raw RPC/shell/session/provider/permission
|
||||||
|
command is accepted. Leading slash text, including leading whitespace, receives
|
||||||
|
`text-policy` refusal in the model. Complete slash/resource suppression and native
|
||||||
|
command interpretation still block CHAT-03 under B3.
|
||||||
|
|
||||||
|
| Commands | Required authority and transition |
|
||||||
|
|---|---|
|
||||||
|
| observe | observe plus authorized source/cursor; no launch/control |
|
||||||
|
| create-draft, update-draft, discard-draft, list-drafts | draft grant; actor/conversation/branch ownership; unsent state and revision CAS for mutations |
|
||||||
|
| begin-upload, append-upload, complete-upload, discard-upload | upload grant; private recipient scope, revision and committed offset CAS; verify staged bytes/digest before completion |
|
||||||
|
| issue-confirmation, answer-confirmation | confirm grant; issuance also needs the selected destructive-operation grant; actor, connection incarnation, target, expiry and pending-state checks |
|
||||||
|
| prompt | send/current controller; own exact unsent draft and valid uploads; freeze and queue, no blind interrupt |
|
||||||
|
| edit-queued, cancel-queued | send/current controller; owned queued item and exact revision; refuse dispatch races |
|
||||||
|
| takeover | independent take-control plus observe, active/open binding, expected generation; refuse self-takeover |
|
||||||
|
| acquire-recovery-control | independent recover-control plus observe, closed admission, absent/disconnected/revoked prior controller and exact confirmation |
|
||||||
|
| approval | approve/current controller, pending stable native decision, current projection/intent and enabled exact supported choice |
|
||||||
|
| interrupt | interrupt/current controller; fence, recover undispatched input, request native cancellation and reconcile |
|
||||||
|
| force-stop | force-stop/current controller and exact confirmation; allowed from active/stopping/uncertain even when work admission is closed |
|
||||||
|
| recover | recover/current controller, exact confirmation and stopped prior cohort/effects proof; eligibility only, no launch |
|
||||||
|
|
||||||
|
Draft creation/editing and upload staging emit no native input. Reload retrieves
|
||||||
|
only the authenticated actor's drafts. Private cursor/large-list support is held
|
||||||
|
by the companion below; the model refuses rather than silently truncating. Failed
|
||||||
|
upload or prompt admission must retain the original text and bytes. The fixture
|
||||||
|
uses synthetic in-memory bytes, not tested durable storage or disk-full recovery.
|
||||||
|
|
||||||
|
At most 10 attachments, 20 MiB/file, 100 MiB/message, narrowed by verified harness
|
||||||
|
limits. `limits.verified:false` disables attachment acceptance in a real consumer;
|
||||||
|
ceiling values alone are not evidence of native support. The miniature upload
|
||||||
|
model tests ownership, byte count, digest, offset, storage capacity and refusal
|
||||||
|
under unknown/stricter synthetic limits, not MIME sniffing or actual native limits. Chunks cap at 256 KiB. A filename is display
|
||||||
|
text only. Never extract/execute uploads or put private staging in an agent's
|
||||||
|
workspace before Send. No implicit TTL removes unsent drafts or uploads.
|
||||||
|
|
||||||
|
Uploads progress receiving -> staged, then transferring/delivered/sent where a
|
||||||
|
verified adapter requires that path. Transfer acknowledgment must bind recipient,
|
||||||
|
incarnation and bytes. A posted acknowledgment ID proves nothing. Frozen uploads
|
||||||
|
cannot be discarded out from under an admitted payload. Preview/download access
|
||||||
|
is NOT authorized by a privateBlobRef.
|
||||||
|
|
||||||
|
### Explicit companion dependencies, not hidden endpoints
|
||||||
|
|
||||||
|
R3 is not a complete deployable UI API. The following are named prerequisite
|
||||||
|
contracts, not authorized implementation tasks or additional queue starts:
|
||||||
|
|
||||||
|
* **CHAT-01C private readback/content companion** must define authenticated
|
||||||
|
attachment preview/download ranges, safe public upload projections, durable
|
||||||
|
queue/receipt/confirmation restoration, and actor-bound paginated private-state
|
||||||
|
snapshots/cursors. Until reviewed, these controls remain unavailable. A small
|
||||||
|
own-draft listing fixture does not claim complete browser/server restart recovery.
|
||||||
|
The UI must not invent a side API or enable durable Send acceptance before this
|
||||||
|
dependency is disposed.
|
||||||
|
* **CHAT-03I ingress/handoff companion** must resolve board Reply, agent-send/tmux,
|
||||||
|
native terminal/internal queued input and launcher/supervisor restart. R3's
|
||||||
|
disposition for unmediated input is `unmediated-ingress` refusal, not successful
|
||||||
|
message delivery. There is no deliver-message operation or authenticated peer
|
||||||
|
sender mapping here. Jason's ruling and a bounded reviewed charter are still
|
||||||
|
required before changing fleet communications. All-seat control acceptance is
|
||||||
|
blocked until this conflict is resolved. Existing live seats are untouched.
|
||||||
|
* **CHAT-03D native dialog companion** must map native confirm/select/input/editor
|
||||||
|
forms without pretending that every extension dialog is a tool permission.
|
||||||
|
They are explicitly unsupported in this draft. No generic yes/no substitution or hidden
|
||||||
|
text-entry endpoint is allowed.
|
||||||
|
|
||||||
|
These dependencies belong on subsequent CHAT task charters before implementation.
|
||||||
|
Their names do not authorize CHAT-02/03, other queue rows, live work or policy grants.
|
||||||
|
|
||||||
|
## Control loss, approvals and stopping
|
||||||
|
|
||||||
|
Takeover atomically increments generation, makes the old controller an observer,
|
||||||
|
recovers only undispatched work and reprojects pending decisions. It does not
|
||||||
|
interrupt active work. Recovered drafts retain original actor, attachments and
|
||||||
|
lineage. A different actor cannot read/adopt them by taking control. Same-actor
|
||||||
|
interfaces may explicitly resend after reconciliation; never resend automatically.
|
||||||
|
|
||||||
|
Disconnect does not release execution ownership, cancel work, allow a pending
|
||||||
|
decision or infer denial. Revocation closes affected ingress, clears the revoked
|
||||||
|
controller reference, increments generation and publishes control transfer.
|
||||||
|
Grant revocation/expiry must trigger the same ingress reconciliation in production;
|
||||||
|
the server fixture explicitly exercises connection revocation. Missing that event
|
||||||
|
producer is a runtime blocker, not permission to retain unsafe control.
|
||||||
|
|
||||||
|
Ordinary takeover is unavailable while fenced. Separately authorized recovery-control
|
||||||
|
acquisition can bind an observer when the previous controller is gone. It increments
|
||||||
|
generation without reopening admission, releasing the execution claim or launching.
|
||||||
|
After reconnection, destructive confirmation must be issued and answered again;
|
||||||
|
confirmation binds connection generation, target, operation and stop context and
|
||||||
|
is single-use. The digest includes the selected recovery stop or current predecessor
|
||||||
|
stop for force-stop. Changing that context requires a new confirmation.
|
||||||
|
Self-takeover is refused rather than becoming a queue-cancel shortcut.
|
||||||
|
|
||||||
|
An active/open controller revocation creates a revocation-mode fence record with
|
||||||
|
the revoked connection and no fabricated client request. A replacement controller
|
||||||
|
may acquire recovery-control, then request server-origin reconciliation. A trusted
|
||||||
|
turnProof must establish cleared native input, no revoked pending input, valid
|
||||||
|
current control and re-projected pending approvals or evidenced terminal decisions.
|
||||||
|
Use `turnState:input-reconciled` and `approvalDisposition:pending-reprojected` for
|
||||||
|
this path; the active turn need not be interrupted. The server records
|
||||||
|
control-reconciled and reopens admission without killing/restarting the engine.
|
||||||
|
Already fenced Interrupt/Force-stop paths retain their existing stop and proof
|
||||||
|
requirements. Grant revocation/expiry producers must use the equivalent protocol.
|
||||||
|
|
||||||
|
`nativeDecision` retains nativeRequest/toolCall/intent/policy identity across control
|
||||||
|
transfer. Each takeover creates a NEW approval projection ID with the same decision
|
||||||
|
reference and unchanged intent display. Old projections become superseded. Dedupe
|
||||||
|
native responses by stable native decision/request identity, not projection ID.
|
||||||
|
`resolvedBy` links to a causal request/actor even after takeover. Once a decision is
|
||||||
|
terminal, an old projection cannot resolve it again.
|
||||||
|
|
||||||
|
The approval includes bounded safe intent text before the tool transcript exists.
|
||||||
|
If the full authorized intent cannot be displayed, do not enable approval. Render
|
||||||
|
exact native labels. Permission-widening choices remain disabled with a reason.
|
||||||
|
Non-permission forms are explicitly blocked by CHAT-03D. A cancel choice is enabled
|
||||||
|
only with an independently verified adapter mapping proving non-allow semantics;
|
||||||
|
Pi select cancellation cannot inherit confirm cancellation's semantics. Claude's
|
||||||
|
permission callback has no invented cancel response.
|
||||||
|
|
||||||
|
A local deadline makes an unresolved decision uncertain until native evidence
|
||||||
|
establishes timeout/cancellation/resolution. Interrupt/Force stop likewise mark
|
||||||
|
pending approvals uncertain, not automatically denied or cancelled. Reconcile
|
||||||
|
native outcomes before reopening. Native denial remains authoritative. No tool
|
||||||
|
callback substitutes for strict tool isolation. A turnProof that settles an uncertain
|
||||||
|
decision must include decisionOutcomes with nativeEvidence. Native resolution with
|
||||||
|
no representable client choice uses `resolved-natively`, not an unexplained resolved
|
||||||
|
state. chosen may then be null, but native evidence may not. This does not invent a
|
||||||
|
human resolvedBy actor for a native timeout or decision.
|
||||||
|
|
||||||
|
Interrupt creates a stop record, closes dispatch and recovers queued drafts. It
|
||||||
|
may advance fenced -> cancelling -> uncertain. A trusted `turnProof` tied to that
|
||||||
|
stop/cohort must establish interrupted turn, cleared native queue with no remaining
|
||||||
|
IDs, native approval disposition and recorded effects. Then publish `reconciled`
|
||||||
|
with the stop reference, mark turn-interrupted and reopen only under current valid
|
||||||
|
control/trust/policy. Before this transition, prompt and ordinary takeover refuse.
|
||||||
|
If cancellation hangs, separately confirmed Force stop remains available.
|
||||||
|
|
||||||
|
For Pi, documented clear_queue precedes abort; returned text cannot reconstruct
|
||||||
|
attachments. Claude cancel_queued is capability-gated; SDK interrupt() alone is
|
||||||
|
insufficient. Neither adapter is proven by these fixtures.
|
||||||
|
|
||||||
|
Force stop creates a new stop record referencing its predecessor, fences input,
|
||||||
|
then records fenced -> cancelling/stopping -> uncertain/stopped. A successor marks
|
||||||
|
its unfinished predecessor superseded, a terminal presentation state. Immediate
|
||||||
|
trusted death/effect evidence may move fenced directly to stopped; intermediate
|
||||||
|
signal observations are not mandatory invented events. Only trusted
|
||||||
|
cohort/effects observations may promote stopped, and the binding cannot lead its
|
||||||
|
stop record. SIGTERM, EOF, abort acknowledgment or idle does not prove death.
|
||||||
|
|
||||||
|
`cohortProof` binds stop, authority, conversation/execution/cohort and membership
|
||||||
|
epoch; it includes complete membership, boot/pid/start identity, per-member death
|
||||||
|
time, observation time and verification digest. `effectReport` lists invocation
|
||||||
|
dispositions with evidence. Recovery derives no-live-members and reconciled effects
|
||||||
|
from these records, not world booleans. The fixture's trusted digest registry stands
|
||||||
|
in for an authenticated supervisor evidence store. Matching a self-posted hash is
|
||||||
|
NOT trust; a real producer/verifier and complete cohort containment remain B3/B4.
|
||||||
|
External effects can remain explicitly uncertain; killing never proves rollback.
|
||||||
|
|
||||||
|
At most one non-stopped binding may hold a conversation or approved native-session
|
||||||
|
identity. Active/reserved duplicates refuse `already-active`; uncertain/stopping
|
||||||
|
or unproven stopped replacements refuse `unsafe-replacement`. Recovery needs the
|
||||||
|
same conversation/branch/config pins, retained/reacquired exclusive claim, current
|
||||||
|
authority and confirmation. It only reports eligibility for separately authorized
|
||||||
|
Resume. Browser/server crash, lost acknowledgment or selecting history cannot start
|
||||||
|
another engine. The actual execution/writer-claim record is deferred to CHAT-02;
|
||||||
|
the single-binding fixture models its assumed retained identity, not a claim store.
|
||||||
|
No private-host command is admitted before Q22/CHAT-04R.
|
||||||
|
|
||||||
|
## Verification and retained gates
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node --check docs/plans/chat-01/check.mjs
|
||||||
|
node docs/plans/chat-01/check.mjs
|
||||||
|
node docs/plans/chat-00/check.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
Node plus installed Python jsonschema 4.26.0, no install/network. Missing validator
|
||||||
|
or invalid schema fails closed. R3 currently tests 98 shapes, 322 required-field
|
||||||
|
omissions, 76 reference cases and seventeen named lifecycle sequences, plus per-item
|
||||||
|
recovery, immutable queue-edit, bootstrap and multipart-stream regressions. These
|
||||||
|
are finite synthetic examples, not comprehensive model checking or runtime proof.
|
||||||
|
|
||||||
|
Explicit model limits accepted for bounded review:
|
||||||
|
|
||||||
|
* Unmediated-ingress refusal is an asserted CHAT-03I disposition, not a modelled
|
||||||
|
ingress path. It is excluded from lifecycle counts. Refusal names are bounded
|
||||||
|
but unregistered draft IDs; no closed public reason-code registry is claimed.
|
||||||
|
* Control transfer rechecks grants/mapping/recording and verified operation support,
|
||||||
|
but intentionally does not require native preflight. It is bookkeeping only;
|
||||||
|
reprojecting an approval never sends a native response. Native actions still gate.
|
||||||
|
* Leading slash text is refused. Other prefixes such as `!` and `@`, and slash
|
||||||
|
commands on later lines, remain admitted unchecked by the miniature text model.
|
||||||
|
Their native interpretation is unverified and held under B3, not declared safe.
|
||||||
|
* publicView uses a separate illustrative predicate which can drift from admission,
|
||||||
|
including native preflight details. It is advisory, never authority. A production
|
||||||
|
view must derive availability from shared authoritative eligibility rules; these
|
||||||
|
fixtures do not prove complete UI refusal-reason coverage.
|
||||||
|
|
||||||
|
Admission fixtures assume authenticated channels and verified operation registries.
|
||||||
|
They do not produce those facts. Native byte limits, permissions, trusted signing,
|
||||||
|
durable transactions, full cursor/readback protocols, all delta/dialog forms,
|
||||||
|
process observation and actual UI behavior remain unverified. Never report a
|
||||||
|
passing synthetic fixture as security enforcement or all-seat acceptance.
|
||||||
|
|
||||||
|
B1/B2 retain exact Pi/Claude branch/image/protocol, trust/settings and tool-isolation
|
||||||
|
proof. B3/B4 retain every-ingress fencing, crash recovery, approved access mappings
|
||||||
|
and supervisor evidence. B5/Q22 retain piece-5/#1508/fleet ordering. B6 requires a
|
||||||
|
separately approved cutover/rollback envelope for each live target. #1507 remains
|
||||||
|
open for the all-seat demonstration and Jason's workday ruling.
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
// CHAT-01 R3: offline draft schemas and idealized lifecycle models only.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import path from 'node:path';
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const schema = JSON.parse(readFileSync(path.join(here, 'contracts.schema.json'), 'utf8'));
|
||||||
|
const f = JSON.parse(readFileSync(path.join(here, 'fixtures.json'), 'utf8'));
|
||||||
|
assert.equal(f.evidence, 'synthetic-draft-contract-only'); assert.equal(f.runtimeEnforcementProven, false);
|
||||||
|
function refs(v) { if (v && typeof v === 'object') { if ('$ref' in v) assert.ok(v.$ref.startsWith('#/$defs/')); Object.values(v).forEach(refs); } }
|
||||||
|
refs(schema);
|
||||||
|
const py = spawnSync('python3', ['-c', String.raw`
|
||||||
|
import json,sys
|
||||||
|
from importlib.metadata import version
|
||||||
|
from jsonschema import Draft202012Validator, FormatChecker
|
||||||
|
from datetime import datetime,timezone
|
||||||
|
s=json.load(open(sys.argv[1]));f=json.load(open(sys.argv[2]));Draft202012Validator.check_schema(s)
|
||||||
|
fc=FormatChecker()
|
||||||
|
@fc.checks('date-time',raises=(ValueError,TypeError))
|
||||||
|
def utc(v):
|
||||||
|
if not isinstance(v,str):return True
|
||||||
|
d=datetime.fromisoformat(v.replace('Z','+00:00'))
|
||||||
|
return v.endswith('Z') and d.utcoffset()==timezone.utc.utcoffset(d)
|
||||||
|
v=Draft202012Validator(s,format_checker=fc);bad=[];omissions=0
|
||||||
|
for c in f['shapeCases']:
|
||||||
|
if v.is_valid(c['value'])!=c['valid']:bad.append(c['id'])
|
||||||
|
for r in f['records']:
|
||||||
|
for key in s['$defs'][r['kind']]['required']:
|
||||||
|
x=dict(r);del x[key];omissions+=1
|
||||||
|
if v.is_valid(x):bad.append(r['kind']+' missing '+key)
|
||||||
|
print(json.dumps({'schemaCases':len(f['shapeCases']),'omissions':omissions,'validator':version('jsonschema'),'failures':bad}))
|
||||||
|
sys.exit(bool(bad))
|
||||||
|
`, path.join(here, 'contracts.schema.json'), path.join(here, 'fixtures.json')], { encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024 });
|
||||||
|
assert.equal(py.status, 0, py.stdout + py.stderr);
|
||||||
|
console.log('Schema', py.stdout.trim());
|
||||||
|
const clone = structuredClone;
|
||||||
|
const sort = v => Array.isArray(v) ? v.map(sort) : v && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, sort(v[k])])) : v;
|
||||||
|
const json = v => JSON.stringify(sort(v));
|
||||||
|
const hash = v => createHash('sha256').update(json(v)).digest('hex');
|
||||||
|
const equal = (a, b) => json(a) === json(b);
|
||||||
|
const without = (v, key) => Object.fromEntries(Object.entries(v).filter(([k]) => k !== key));
|
||||||
|
const bad = s => `refused:${s}`;
|
||||||
|
const record = (kind, fields) => ({ version: 2, kind, ...fields });
|
||||||
|
const targetOf = w => ({ conversation: w.binding.scope.conversation, branch: w.binding.branch, execution: w.binding.execution, controllerGeneration: w.binding.controllerGeneration });
|
||||||
|
const scopeMatch = (a, b) => a.conversation === b.conversation && a.branch === b.branch && a.execution === b.execution;
|
||||||
|
const operationHash = (r, payloadDigest) => hash({ target: r.target, command: r.command, ...(payloadDigest === undefined ? {} : { payloadDigest }) }); // Excludes transport; frozen digest included in durable admissions.
|
||||||
|
const own = (r, c, t) => r && r.actor === c.actor && r.conversation === t.conversation;
|
||||||
|
function patch(w, p) { const keys = p.path.split('.'); const last = keys.pop(); let n = w; for (const k of keys) n = n[k]; assert.ok(last in n, p.path); n[last] = clone(p.value); }
|
||||||
|
function emit(w, type, stop = null) {
|
||||||
|
const e = clone(f.records.find(r => r.kind === 'event'));
|
||||||
|
Object.assign(e, { id: `event-${w.events.length + 1}`, type, stop, target: targetOf(w), sequence: w.events.length + 1, request: null, entry: null, contentIndex: null, updateMode: 'none', content: [], message: null, role: null, part: null, lastPart: null, createdAt: w.now });
|
||||||
|
w.events.push(e);
|
||||||
|
}
|
||||||
|
function recoverQueue(w, selected = null) {
|
||||||
|
const ids = [], failed = [];
|
||||||
|
const pending = w.queue.filter(q => q.state === 'queued' && (!selected || q.id === selected));
|
||||||
|
// Classify the whole selection before committing dispositions. Corrupt bytes
|
||||||
|
// must not prevent control transfer or be reconstructed as invented drafts.
|
||||||
|
const usable = new Map(pending.map(q => {
|
||||||
|
const p = w.payloads.find(p => p.id === q.frozenPayload);
|
||||||
|
return [q.id, p && hash(without(p, 'digest')) === q.payloadDigest && p.digest === q.payloadDigest ? p : null];
|
||||||
|
}));
|
||||||
|
for (const q of pending) {
|
||||||
|
const payload = usable.get(q.id);
|
||||||
|
if (!payload) { q.state = 'recovery-failed'; q.failureReason = 'payload'; failed.push(q.id); emit(w, 'queue-changed'); continue; }
|
||||||
|
const draft = record('draft', { id: `recovered-${q.id}`, actor: q.actor, conversation: q.target.conversation, branch: q.target.branch, revision: 1, text: payload.text, attachments: payload.uploads.map(u => u.id), state: 'unsent', recoveredFrom: q.id, createdAt: w.now });
|
||||||
|
w.drafts.push(draft); q.state = 'recovered-as-draft'; q.recoveredDraft = draft.id; ids.push(draft.id);
|
||||||
|
}
|
||||||
|
return { drafts: ids, failed };
|
||||||
|
}
|
||||||
|
function reproject(w) {
|
||||||
|
for (const a of [...w.approvals]) {
|
||||||
|
const d = w.decisions.find(d => d.id === a.decision);
|
||||||
|
if (a.state !== 'pending' || d?.state !== 'pending') continue;
|
||||||
|
a.state = 'superseded';
|
||||||
|
w.approvals.push({ ...clone(a), id: `${a.id}-g${w.binding.controllerGeneration}`, target: targetOf(w), state: 'pending' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function transfer(w, c, recovery) {
|
||||||
|
recoverQueue(w);
|
||||||
|
const old = w.connections.find(x => x.id === w.binding.controllerConnection);
|
||||||
|
if (old) old.mode = 'observer';
|
||||||
|
w.binding.controllerGeneration++; w.binding.controllerConnection = c.id; c.mode = 'controller';
|
||||||
|
reproject(w); emit(w, 'control-transferred', w.binding.stop);
|
||||||
|
// Recovery-control acquisition never opens work admission or launches.
|
||||||
|
return recovery ? 'recovery-control-acquired' : 'transferred';
|
||||||
|
}
|
||||||
|
function checkConfirmation(w, r, c, op) {
|
||||||
|
const x = w.confirmations.find(x => x.id === r.command.confirmation);
|
||||||
|
if (!x || x.state !== 'confirmed' || x.actor !== c.actor || x.connection !== c.id || x.connectionGeneration !== c.generation || x.operation !== op || !equal(x.target, r.target) || x.stop !== w.binding.stop || (op === 'recover' && x.stop !== r.command.stop) || x.intentDigest !== hash({ target: r.target, operation: op, stop: x.stop }) || Date.parse(x.expiresAt) <= Date.parse(w.now)) return false;
|
||||||
|
x.state = 'consumed'; return true;
|
||||||
|
}
|
||||||
|
function proof(w, id, kind, stopId) {
|
||||||
|
const p = w.proofs.find(p => p.id === id && p.kind === kind);
|
||||||
|
if (!p || !w.trustedAuthorities.includes(p.authority) || p.conversation !== w.binding.scope.conversation || p.execution !== w.binding.execution || p.cohortRef !== w.binding.cohortRef || p.stop !== stopId || Date.parse(p.observedAt) > Date.parse(w.now)) return null;
|
||||||
|
const digest = hash(without(p, 'verificationDigest'));
|
||||||
|
return digest === p.verificationDigest && w.trustedProofs[p.id] === digest ? p : null;
|
||||||
|
}
|
||||||
|
function effects(w, id, stopId) { const p = proof(w, id, 'effectReport', stopId); return p && p.invocations.every(i => ['completed', 'uncertain', 'not-started'].includes(i.disposition) && (i.disposition === 'not-started' || i.evidence)); }
|
||||||
|
function stopped(w, s) {
|
||||||
|
if (!s || s.state !== 'stopped' || w.binding.stop !== s.id || !scopeMatch(s.target, targetOf(w))) return false;
|
||||||
|
const p = proof(w, s.supervisorEvidence, 'cohortProof', s.id);
|
||||||
|
return p && p.membershipComplete && p.membershipEpoch === w.membershipEpoch &&
|
||||||
|
new Set(p.members.map(m => `${m.boot}:${m.pid}:${m.startTicks}`)).size === p.members.length &&
|
||||||
|
p.members.every(m => m.terminatedAt && Date.parse(m.terminatedAt) <= Date.parse(p.observedAt)) && effects(w, s.effectsEvidence, s.id);
|
||||||
|
}
|
||||||
|
function startStop(w, r, mode) {
|
||||||
|
w.binding.admission = 'closed';
|
||||||
|
const recovered = recoverQueue(w);
|
||||||
|
const predecessor = w.stops.find(s => s.id === w.binding.stop);
|
||||||
|
if (predecessor && !['stopped', 'turn-interrupted', 'superseded'].includes(predecessor.state)) predecessor.state = 'superseded';
|
||||||
|
const s = record('stop', { id: `stop-${w.stops.length + 1}`, request: r.id, target: clone(r.target), mode, state: 'fenced', queueDrafts: recovered.drafts, queueFailures: recovered.failed, revokedConnection: mode === 'revocation' ? r.connection : null, cohortRef: w.binding.cohortRef, supervisorEvidence: null, effectsEvidence: null, externalEffects: 'uncertain', createdAt: w.now, nativeQueue: 'pending', approvalDisposition: 'pending', turnEvidence: null, supersedes: w.binding.stop });
|
||||||
|
w.stops.push(s); w.binding.stop = s.id;
|
||||||
|
// A cancellation request is not proof that a native approval was cancelled.
|
||||||
|
if (mode !== 'revocation') {
|
||||||
|
for (const d of w.decisions) if (d.state === 'pending') d.state = 'uncertain';
|
||||||
|
for (const a of w.approvals) if (a.state === 'pending') a.state = 'uncertain';
|
||||||
|
}
|
||||||
|
if (mode === 'force-stop') w.binding.state = 'stopping';
|
||||||
|
if (mode !== 'revocation') emit(w, 'stopping', s.id);
|
||||||
|
return `${mode}-fenced`;
|
||||||
|
}
|
||||||
|
const caps = { observe: 'observe', prompt: 'send', takeover: 'take-control', 'edit-queued': 'send', 'cancel-queued': 'send', approval: 'approve', interrupt: 'interrupt', 'force-stop': 'force-stop', recover: 'recover', 'acquire-recovery-control': 'recover-control', 'list-drafts': 'draft', 'create-draft': 'draft', 'update-draft': 'draft', 'discard-draft': 'draft', 'begin-upload': 'upload', 'append-upload': 'upload', 'complete-upload': 'upload', 'discard-upload': 'upload', 'issue-confirmation': 'confirm', 'answer-confirmation': 'confirm' };
|
||||||
|
function uploadValid(w, u, c, t) { return own(u, c, t) && equal(u.recipientScope, w.binding.scope) && ['staged', 'delivered'].includes(u.state) && u.receivedBytes === u.size && (u.state !== 'delivered' || u.transferAck); }
|
||||||
|
function attachmentLimits(w, uploads) {
|
||||||
|
if (!uploads.length) return true;
|
||||||
|
const n = w.nativeLimits;
|
||||||
|
return n?.verified && uploads.length <= Math.min(10, n.attachments) && uploads.every(u => u.size <= Math.min(20 * 1024 * 1024, n.fileBytes)) && uploads.reduce((sum, u) => sum + u.size, 0) <= Math.min(100 * 1024 * 1024, n.messageBytes);
|
||||||
|
}
|
||||||
|
function freeze(w, d) {
|
||||||
|
const p = record('frozenPayload', { id: `payload-${w.payloads.length + 1}`, actor: d.actor, conversation: d.conversation, branch: d.branch, draft: d.id, draftRevision: d.revision, text: d.text, uploads: d.attachments.map(id => clone(w.uploads.find(u => u.id === id))), digest: '', createdAt: w.now });
|
||||||
|
p.digest = hash(without(p, 'digest')); w.payloads.push(p); return p;
|
||||||
|
}
|
||||||
|
function evaluate(w, r, phase = 'admission') {
|
||||||
|
const c = w.connections.find(c => c.id === r.connection), b = w.binding, t = r.target, cmd = r.command, op = cmd.operation;
|
||||||
|
if (!c || c.state === 'revoked' || (phase !== 'dispatch' && (c.state !== 'connected' || !w.authenticatedChannels.includes(c.authenticatedChannelRef)))) return bad('channel');
|
||||||
|
// Scheduler authority comes from durable admission and the retained claim;
|
||||||
|
// ordinary browser disconnect is not revocation or a new work fence.
|
||||||
|
if (c.transport === 'private-host') return bad('unsupported-capability'); // Q22/CHAT-04R held.
|
||||||
|
const g = w.grants.find(g => g.id === c.grant);
|
||||||
|
if (!g || g.actor !== c.actor || g.revision !== c.grantRevision || g.state !== 'active' || Date.parse(g.expiresAt) <= Date.parse(w.now)) return bad('grant');
|
||||||
|
if (!equal(g.scope, b.scope) || c.conversation !== b.scope.conversation) return bad('scope');
|
||||||
|
if (!w.approvedMappings.includes(b.sourceRootRef)) return bad('mapping');
|
||||||
|
if (!w.auditWritable) return bad('audit');
|
||||||
|
if (!g.capabilities.includes(caps[op])) return bad('capability');
|
||||||
|
if (!w.verifiedOperations.includes(op)) return bad('unsupported-capability');
|
||||||
|
if (t.conversation !== b.scope.conversation || t.execution !== b.execution) return bad('target');
|
||||||
|
if (op === 'observe') {
|
||||||
|
if (!w.viewBranches.includes(t.branch)) return bad('target');
|
||||||
|
if (cmd.cursor && (cmd.cursor !== w.cursor.id || w.cursor.actor !== c.actor || w.cursor.purpose !== 'history' || w.cursor.conversation !== t.conversation || !Number.isFinite(Date.parse(w.cursor.expiresAt)) || Date.parse(w.cursor.expiresAt) <= Date.parse(w.now) || w.cursor.branch !== t.branch || w.cursor.sourceEpoch !== w.sourceEpoch || w.cursor.snapshotDigest !== b.snapshotDigest)) return bad('cursor');
|
||||||
|
return 'observing';
|
||||||
|
}
|
||||||
|
if (t.branch !== b.branch) return bad('target');
|
||||||
|
if (phase === 'admission' && w.requestIndex && (w.requestIndex.id ?? 'client-request-1') === r.id) {
|
||||||
|
if (w.requestIndex.actor !== c.actor || w.requestIndex.digest !== operationHash(r)) return bad('conflicting-request');
|
||||||
|
const admitted = w.requests.find(a => a.clientRequest === r.id && a.actor === c.actor);
|
||||||
|
const queued = w.queue.find(q => q.request === admitted?.id);
|
||||||
|
return `existing:${queued?.state ?? w.requestIndex.state}`;
|
||||||
|
}
|
||||||
|
// Private draft/upload actions need visibility and their own grant, not control.
|
||||||
|
if (op === 'list-drafts') {
|
||||||
|
const drafts = w.drafts.filter(d => own(d, c, t) && d.branch === t.branch);
|
||||||
|
// Never silently truncate or invent an unreviewed private cursor protocol.
|
||||||
|
if (cmd.cursor || drafts.length > cmd.limit) return bad('readback-contract');
|
||||||
|
w.lastPrivateListing = clone(drafts); return 'drafts-listed';
|
||||||
|
}
|
||||||
|
if (op === 'create-draft') {
|
||||||
|
w.drafts.push(record('draft', { id: `draft-${w.drafts.length + 1}`, actor: c.actor, conversation: t.conversation, branch: t.branch, revision: 1, text: cmd.text, attachments: [], state: 'unsent', recoveredFrom: null, createdAt: w.now })); return 'draft-created';
|
||||||
|
}
|
||||||
|
if (['update-draft', 'discard-draft'].includes(op)) {
|
||||||
|
const d = w.drafts.find(d => d.id === cmd.draft);
|
||||||
|
if (!own(d, c, t) || d.branch !== t.branch || d.state !== 'unsent' || d.revision !== cmd.revision) return bad('draft');
|
||||||
|
if (op === 'discard-draft') { d.state = 'discarded'; d.revision++; return 'draft-discarded'; }
|
||||||
|
if (cmd.attachments.some(id => !uploadValid(w, w.uploads.find(u => u.id === id), c, t))) return bad('upload');
|
||||||
|
if (cmd.attachments.reduce((n, id) => n + w.uploads.find(u => u.id === id).size, 0) > 100 * 1024 * 1024) return bad('upload');
|
||||||
|
d.text = cmd.text; d.attachments = [...cmd.attachments]; d.revision++; return 'draft-updated';
|
||||||
|
}
|
||||||
|
if (op === 'begin-upload') {
|
||||||
|
if (w.privateStorageCapacity < cmd.size) return bad('capacity');
|
||||||
|
const serial = w.uploads.filter(u => u.id.startsWith('upload-new-')).length + 1;
|
||||||
|
const u = record('upload', { id: `upload-new-${serial}`, actor: c.actor, conversation: t.conversation, revision: 1, filename: cmd.filename, mimeType: cmd.mimeType, size: cmd.size, digest: cmd.digest, state: 'receiving', privateBlobRef: `blob-new-${serial}`, recipientScope: clone(b.scope), transferAck: null, createdAt: w.now, receivedBytes: 0 });
|
||||||
|
w.uploads.push(u); w.blobBytes[u.id] = ''; w.privateStorageCapacity -= u.size; return 'upload-receiving';
|
||||||
|
}
|
||||||
|
if (['append-upload', 'complete-upload', 'discard-upload'].includes(op)) {
|
||||||
|
const u = w.uploads.find(u => u.id === cmd.upload);
|
||||||
|
if (!own(u, c, t) || !equal(u.recipientScope, b.scope) || u.revision !== cmd.revision) return bad('upload');
|
||||||
|
if (op === 'discard-upload') {
|
||||||
|
if (w.payloads.some(p => p.uploads.some(x => x.id === u.id))) return bad('upload-in-use');
|
||||||
|
u.state = 'discarded'; return 'upload-discarded';
|
||||||
|
}
|
||||||
|
if (u.state !== 'receiving') return bad('upload');
|
||||||
|
if (typeof w.blobBytes[u.id] !== 'string') return bad('storage');
|
||||||
|
if (op === 'append-upload') {
|
||||||
|
const bytes = Buffer.from(cmd.dataBase64, 'base64');
|
||||||
|
if (bytes.toString('base64') !== cmd.dataBase64 || cmd.offset !== u.receivedBytes || bytes.length > 256 * 1024 || u.receivedBytes + bytes.length > u.size) return bad('upload');
|
||||||
|
const all = Buffer.concat([Buffer.from(w.blobBytes[u.id], 'base64'), bytes]);
|
||||||
|
w.blobBytes[u.id] = all.toString('base64'); u.receivedBytes = all.length; u.revision++; return 'upload-appended';
|
||||||
|
}
|
||||||
|
const bytes = Buffer.from(w.blobBytes[u.id], 'base64');
|
||||||
|
if (bytes.length !== u.size || createHash('sha256').update(bytes).digest('hex') !== u.digest) return bad('upload');
|
||||||
|
u.state = 'staged'; u.revision++; return 'upload-staged';
|
||||||
|
}
|
||||||
|
if (t.controllerGeneration !== b.controllerGeneration) return bad('generation');
|
||||||
|
if (op === 'issue-confirmation') {
|
||||||
|
const needed = caps[cmd.operationToConfirm];
|
||||||
|
if (!g.capabilities.includes(needed)) return bad('capability');
|
||||||
|
w.confirmations.push(record('confirmation', { id: `confirmation-${w.confirmations.length + 1}`, actor: c.actor, connection: c.id, connectionGeneration: c.generation, target: clone(t), operation: cmd.operationToConfirm, stop: b.stop, intentDigest: hash({ target: t, operation: cmd.operationToConfirm, stop: b.stop }), expiresAt: new Date(Date.parse(w.now) + 60000).toISOString(), state: 'pending' })); return 'confirmation-issued';
|
||||||
|
}
|
||||||
|
if (op === 'answer-confirmation') {
|
||||||
|
const x = w.confirmations.find(x => x.id === cmd.confirmation);
|
||||||
|
if (!x || x.state !== 'pending' || x.actor !== c.actor || x.connection !== c.id || x.connectionGeneration !== c.generation || !equal(x.target, t) || Date.parse(x.expiresAt) <= Date.parse(w.now)) return bad('confirmation');
|
||||||
|
x.state = cmd.answer === 'confirm' ? 'confirmed' : 'cancelled'; return `confirmation-${x.state}`;
|
||||||
|
}
|
||||||
|
if (op === 'takeover') {
|
||||||
|
if (c.id === b.controllerConnection) return bad('already-controller');
|
||||||
|
if (!g.capabilities.includes('observe')) return bad('capability');
|
||||||
|
if (b.state !== 'active' || b.admission !== 'open') return bad('fenced');
|
||||||
|
return transfer(w, c, false);
|
||||||
|
}
|
||||||
|
if (op === 'acquire-recovery-control') {
|
||||||
|
if (!g.capabilities.includes('observe')) return bad('capability');
|
||||||
|
if (b.admission !== 'closed') return bad('fenced');
|
||||||
|
const old = w.connections.find(x => x.id === b.controllerConnection);
|
||||||
|
if (old?.state === 'connected') return bad('controller-present');
|
||||||
|
if (!checkConfirmation(w, r, c, op)) return bad('confirmation');
|
||||||
|
return transfer(w, c, true);
|
||||||
|
}
|
||||||
|
if (c.id !== b.controllerConnection || c.mode !== 'controller') return bad('controller');
|
||||||
|
if (op === 'force-stop') {
|
||||||
|
if (!['active', 'stopping', 'uncertain'].includes(b.state)) return bad('fenced');
|
||||||
|
if (!checkConfirmation(w, r, c, op)) return bad('confirmation');
|
||||||
|
return startStop(w, r, op); // Safety escalation stays usable while work is fenced.
|
||||||
|
}
|
||||||
|
if (op === 'recover') {
|
||||||
|
const s = w.stops.find(s => s.id === cmd.stop);
|
||||||
|
if (b.state !== 'stopped' || b.admission !== 'closed' || !stopped(w, s)) return bad('stop-proof');
|
||||||
|
if (!checkConfirmation(w, r, c, op)) return bad('confirmation');
|
||||||
|
return 'recovery-eligible'; // No engine start and no admission reopening.
|
||||||
|
}
|
||||||
|
if (!w.trustVerified || !w.policyVerified) return bad('preflight');
|
||||||
|
if (b.state !== 'active' || b.admission !== 'open') return bad('fenced');
|
||||||
|
if (op === 'interrupt') return startStop(w, r, op);
|
||||||
|
if (op === 'approval') {
|
||||||
|
const a = w.approvals.find(a => a.id === cmd.approval), d = w.decisions.find(d => d.id === a?.decision);
|
||||||
|
if (d && ['resolved', 'resolved-natively', 'expired', 'cancelled'].includes(d.state)) return bad('decision-completed');
|
||||||
|
if (!a || !d || a.state !== 'pending' || d.state !== 'pending' || !equal(a.target, t) || !scopeMatch(d, t) || a.intentDigest !== cmd.intentDigest || d.intentDigest !== cmd.intentDigest || d.policyRevision !== b.policyRevision) return bad('approval');
|
||||||
|
if (a.dialogForm !== 'permission' || a.unsupportedReason || a.intentDisplay.visibility !== 'available' || !a.intentDisplay.complete) return bad('unsupported-capability');
|
||||||
|
if (a.deadline && Date.parse(a.deadline) <= Date.parse(w.now)) { a.state = 'uncertain'; d.state = 'uncertain'; return bad('approval-uncertain'); }
|
||||||
|
const choice = a.choices.find(x => x.id === cmd.choice);
|
||||||
|
if (!choice?.enabled || choice.effect === 'permission-change' || (choice.effect === 'cancel' && (choice.cancelSafety !== 'proven-non-allow' || !w.verifiedCancelMappings.includes(choice.mappingEvidence)))) return bad('choice');
|
||||||
|
d.state = a.state = 'resolved'; d.chosen = a.chosen = choice.id; d.resolvedBy = a.resolvedBy = r.id;
|
||||||
|
w.requests.push(record('request', { id: r.id, clientRequest: r.id, connection: c.id, actor: c.actor, grant: g.id, grantRevision: g.revision, target: clone(t), operationDigest: operationHash(r), command: clone(cmd), admittedAt: w.now, frozenPayload: null }));
|
||||||
|
w.outbox.push({ operation: 'native-approval', decision: d.id, nativeRequest: d.nativeRequest, request: r.id }); return 'approval-resolved';
|
||||||
|
}
|
||||||
|
if (phase === 'dispatch') {
|
||||||
|
const q = w.queue.find(q => q.request === w.requests.find(x => x.clientRequest === r.id)?.id), admitted = w.requests.find(x => x.id === q?.request);
|
||||||
|
if (!q || !admitted || q.state !== 'queued') return bad('queue-race');
|
||||||
|
if (q.actor !== c.actor || admitted.actor !== c.actor || admitted.grant !== g.id || admitted.grantRevision !== g.revision || !equal(q.target, t) || !equal(admitted.target, t) || q.policyRevision !== b.policyRevision || q.grantRevision !== g.revision) return bad('target');
|
||||||
|
const p = w.payloads.find(p => p.id === q.frozenPayload && p.id === admitted.frozenPayload);
|
||||||
|
if (!p || p.digest !== q.payloadDigest || hash(without(p, 'digest')) !== q.payloadDigest || p.actor !== c.actor || p.conversation !== t.conversation || p.branch !== t.branch) return bad('payload');
|
||||||
|
if (!attachmentLimits(w, p.uploads)) return bad('unsupported-capability');
|
||||||
|
if (admitted.operationDigest !== operationHash(admitted, p.digest) || !equal(admitted.command, cmd) || q.revision !== (cmd.operation === 'edit-queued' ? cmd.revision + 1 : 1)) return bad('payload');
|
||||||
|
if (p.text.trimStart().startsWith('/')) return bad('text-policy');
|
||||||
|
q.state = 'dispatched'; w.outbox.push({ operation: 'prompt', payload: p.id, actor: q.actor }); return 'dispatched';
|
||||||
|
}
|
||||||
|
if (['edit-queued', 'cancel-queued'].includes(op)) {
|
||||||
|
const q = w.queue.find(x => x.id === cmd.item);
|
||||||
|
if (!q || q.state !== 'queued' || q.revision !== cmd.revision || q.actor !== c.actor || !equal(q.target, t)) return bad('queue-race');
|
||||||
|
if (op === 'cancel-queued') { recoverQueue(w, q.id); return q.state; }
|
||||||
|
}
|
||||||
|
if (['prompt', 'edit-queued'].includes(op)) {
|
||||||
|
const d = w.drafts.find(x => x.id === cmd.draft);
|
||||||
|
if (!own(d, c, t) || d.branch !== t.branch || d.state !== 'unsent' || d.revision !== cmd.draftRevision) return bad('draft');
|
||||||
|
if (d.text.trimStart().startsWith('/')) return bad('text-policy');
|
||||||
|
const uploads = d.attachments.map(id => w.uploads.find(u => u.id === id));
|
||||||
|
if (uploads.some(u => !uploadValid(w, u, c, t)) || uploads.reduce((n, u) => n + u.size, 0) > 100 * 1024 * 1024) return bad('upload');
|
||||||
|
if (!attachmentLimits(w, uploads)) return bad('unsupported-capability');
|
||||||
|
const p = freeze(w, d); d.state = 'submitted';
|
||||||
|
const admitted = record('request', { id: `request-${w.requests.length + 1}`, clientRequest: r.id, connection: c.id, actor: c.actor, grant: g.id, grantRevision: g.revision, target: clone(t), operationDigest: operationHash(r, p.digest), command: clone(cmd), admittedAt: w.now, frozenPayload: p.id });
|
||||||
|
w.requests.push(admitted);
|
||||||
|
if (op === 'edit-queued') {
|
||||||
|
const q = w.queue.find(q => q.id === cmd.item);
|
||||||
|
q.request = admitted.id; q.draft = d.id; q.frozenPayload = p.id; q.payloadDigest = p.digest; q.revision++;
|
||||||
|
return 'queue-edited'; // Old admission and frozen bytes remain unchanged.
|
||||||
|
}
|
||||||
|
w.queue.push(record('queueItem', { id: `queue-${w.queue.length + 1}`, request: admitted.id, actor: c.actor, target: clone(t), revision: 1, draft: d.id, payloadDigest: p.digest, state: 'queued', nativeRequest: null, recoveredDraft: null, createdAt: w.now, frozenPayload: p.id, policyRevision: b.policyRevision, grantRevision: g.revision, failureReason: null }));
|
||||||
|
w.requestIndex = { id: r.id, actor: c.actor, digest: operationHash(r), state: 'queued' }; return 'queued';
|
||||||
|
}
|
||||||
|
return bad('operation');
|
||||||
|
}
|
||||||
|
function dispatch(w, queueId) {
|
||||||
|
const q = w.queue.find(q => q.id === queueId), a = w.requests.find(a => a.id === q?.request);
|
||||||
|
if (!a || q.state !== 'queued') return bad('queue-race');
|
||||||
|
// Scheduler resolves stored admission, never a new client/draft envelope.
|
||||||
|
const outcome = evaluate(w, { version: 2, kind: 'clientRequest', id: a.clientRequest, connection: a.connection, target: clone(a.target), command: clone(a.command) }, 'dispatch');
|
||||||
|
if (outcome.startsWith('refused:') && q.state === 'queued') {
|
||||||
|
q.state = 'dispatch-refused'; q.failureReason = outcome.slice('refused:'.length); emit(w, 'queue-changed');
|
||||||
|
}
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
// Server-origin transitions; no corresponding untrusted client command exists.
|
||||||
|
function server(w, op, fields = {}) {
|
||||||
|
if (op === 'revoke-connection') {
|
||||||
|
const c = w.connections.find(c => c.id === fields.connection); c.state = 'revoked'; c.mode = 'observer';
|
||||||
|
if (w.binding.controllerConnection === c.id) {
|
||||||
|
if (w.binding.state === 'active' && w.binding.admission === 'open') startStop(w, { id: null, connection: c.id, target: targetOf(w) }, 'revocation');
|
||||||
|
w.binding.controllerConnection = null; w.binding.controllerGeneration++; w.binding.admission = 'closed'; emit(w, 'control-transferred', w.binding.stop); }
|
||||||
|
return 'revoked';
|
||||||
|
}
|
||||||
|
if (op === 'reconcile-interrupt' || op === 'reconcile-revocation') {
|
||||||
|
const revocation = op === 'reconcile-revocation';
|
||||||
|
const s = w.stops.find(s => s.id === w.binding.stop), p = proof(w, fields.proof, 'turnProof', s?.id);
|
||||||
|
if (!s || s.mode !== (revocation ? 'revocation' : 'interrupt') || !p || p.turnState !== (revocation ? 'input-reconciled' : 'interrupted') || p.nativeQueue !== 'cleared' || p.nativePending.length || p.approvalDisposition === 'uncertain' || !effects(w, p.effectsEvidence, s.id)) return bad('reconciliation');
|
||||||
|
if (revocation && (p.approvalDisposition !== 'pending-reprojected' || !w.connections.some(c => c.id === s.revokedConnection && c.state === 'revoked') || w.decisions.some(d => d.state === 'pending' && !w.approvals.some(a => a.decision === d.id && a.state === 'pending' && equal(a.target, targetOf(w)))))) return bad('reconciliation');
|
||||||
|
const uncertain = w.decisions.filter(d => d.state === 'uncertain');
|
||||||
|
if (new Set(p.decisionOutcomes.map(x => x.decision)).size !== p.decisionOutcomes.length || uncertain.some(d => !p.decisionOutcomes.some(x => x.decision === d.id && x.nativeEvidence && ['cancelled', 'resolved-natively'].includes(x.state)))) return bad('reconciliation');
|
||||||
|
const c = w.connections.find(c => c.id === w.binding.controllerConnection), g = w.grants.find(g => g.id === c?.grant);
|
||||||
|
if (!c || c.state !== 'connected' || !g || g.state !== 'active' || g.actor !== c.actor || g.revision !== c.grantRevision || Date.parse(g.expiresAt) <= Date.parse(w.now) || !equal(g.scope, w.binding.scope) || !w.authenticatedChannels.includes(c.authenticatedChannelRef) || !w.approvedMappings.includes(w.binding.sourceRootRef) || !w.trustVerified || !w.policyVerified || !w.auditWritable) return bad('controller');
|
||||||
|
s.state = revocation ? 'control-reconciled' : 'turn-interrupted'; s.nativeQueue = 'cleared'; s.approvalDisposition = revocation ? 'pending' : p.approvalDisposition; s.turnEvidence = p.id; s.effectsEvidence = p.effectsEvidence;
|
||||||
|
for (const d of uncertain) {
|
||||||
|
const x = p.decisionOutcomes.find(x => x.decision === d.id);
|
||||||
|
if (!x) continue;
|
||||||
|
d.state = x.state; d.chosen = x.chosen; d.nativeEvidence = x.nativeEvidence;
|
||||||
|
for (const a of w.approvals.filter(a => a.decision === d.id && a.state === 'uncertain')) { a.state = x.state; a.chosen = x.chosen; }
|
||||||
|
}
|
||||||
|
w.binding.admission = 'open'; emit(w, 'reconciled', s.id); return 'reconciled';
|
||||||
|
}
|
||||||
|
if (op === 'advance-stop') {
|
||||||
|
const s = w.stops.find(s => s.id === w.binding.stop);
|
||||||
|
const next = { fenced: ['cancelling', 'stopping', 'uncertain'], cancelling: ['stopping', 'uncertain'], stopping: ['uncertain'], uncertain: [] };
|
||||||
|
if (!s || !next[s.state]?.includes(fields.state)) return bad('stop-transition');
|
||||||
|
s.state = fields.state; if (s.mode === 'force-stop') w.binding.state = fields.state === 'uncertain' ? 'uncertain' : 'stopping'; return fields.state;
|
||||||
|
}
|
||||||
|
if (op === 'confirm-stopped') {
|
||||||
|
const s = w.stops.find(s => s.id === w.binding.stop); if (!s || s.mode !== 'force-stop') return bad('stop-proof');
|
||||||
|
const trial = { ...s, state: 'stopped', supervisorEvidence: fields.proof, effectsEvidence: fields.effects };
|
||||||
|
if (!stopped(w, trial)) return bad('stop-proof');
|
||||||
|
Object.assign(s, trial); w.binding.state = 'stopped'; w.binding.admission = 'closed'; emit(w, 'stopped', s.id); return 'stopped';
|
||||||
|
}
|
||||||
|
return bad('server-operation');
|
||||||
|
}
|
||||||
|
function admitBinding(w, proposed) {
|
||||||
|
const conflicts = w.bindings.filter(b => b.scope.conversation === proposed.scope.conversation || (b.scope.host === proposed.scope.host && b.harness === proposed.harness && b.sourceRootRef === proposed.sourceRootRef && b.nativeSession === proposed.nativeSession));
|
||||||
|
if (conflicts.some(b => ['reserved', 'active'].includes(b.state))) return bad('already-active');
|
||||||
|
if (conflicts.some(b => b.state !== 'stopped')) return bad('unsafe-replacement');
|
||||||
|
if (conflicts.length && !stopped(w, w.stops.find(s => s.id === w.binding.stop))) return bad('unsafe-replacement');
|
||||||
|
return 'reservation-eligible'; // Never launches.
|
||||||
|
}
|
||||||
|
for (const c of f.stateCases) {
|
||||||
|
const w = clone(f.world); c.patches.forEach(p => patch(w, p));
|
||||||
|
assert.equal(c.phase === 'dispatch' ? dispatch(w, w.queue[0].id) : evaluate(w, c.request), c.expected, c.id);
|
||||||
|
if (c.phase === 'dispatch' && c.expected.startsWith('refused:')) assert.notEqual(w.queue[0].state, 'queued', c.id + ' disposition');
|
||||||
|
}
|
||||||
|
console.log(`Reference cases ${f.stateCases.length} pass`);
|
||||||
|
// Helpers below create only synthetic trusted-source observations for sequences.
|
||||||
|
let reqCounter = 10;
|
||||||
|
function command(w, op, connection = 'connection-1', fields = {}) { return { version: 2, kind: 'clientRequest', id: `seq-${++reqCounter}`, connection, target: targetOf(w), command: { ...clone(f.commands.find(c => c.operation === op)), ...fields } }; }
|
||||||
|
function confirmation(w, op, connection = 'connection-1') {
|
||||||
|
assert.equal(evaluate(w, command(w, 'issue-confirmation', connection, { operationToConfirm: op })), 'confirmation-issued');
|
||||||
|
const id = w.confirmations.at(-1).id;
|
||||||
|
assert.equal(evaluate(w, command(w, 'answer-confirmation', connection, { confirmation: id, answer: 'confirm' })), 'confirmation-confirmed'); return id;
|
||||||
|
}
|
||||||
|
function trustedProof(w, kind, stopId) {
|
||||||
|
const p = clone(f.records.find(r => r.kind === kind)); p.stop = stopId; p.id = `${kind}-${stopId}`;
|
||||||
|
if (kind === 'turnProof') p.effectsEvidence = `effectReport-${stopId}`;
|
||||||
|
p.verificationDigest = hash(without(p, 'verificationDigest')); w.proofs.push(p); w.trustedProofs[p.id] = p.verificationDigest; return p.id;
|
||||||
|
}
|
||||||
|
const results = [];
|
||||||
|
function sequence(name, fn) { fn(); results.push(name); console.log('PASS sequence', name); }
|
||||||
|
sequence('interrupt-reconcile-prompt', () => {
|
||||||
|
const w = clone(f.world); assert.equal(evaluate(w, command(w, 'interrupt')), 'interrupt-fenced');
|
||||||
|
assert.equal(evaluate(w, command(w, 'prompt')), bad('fenced'));
|
||||||
|
assert.equal(evaluate(w, command(w, 'takeover', 'connection-2')), bad('fenced'));
|
||||||
|
const s = w.stops.at(-1); trustedProof(w, 'effectReport', s.id); const p = trustedProof(w, 'turnProof', s.id);
|
||||||
|
assert.equal(server(w, 'reconcile-interrupt', { proof: p }), 'reconciled');
|
||||||
|
assert.equal(w.events.at(-1).stop, s.id); assert.equal(evaluate(w, command(w, 'prompt')), 'queued');
|
||||||
|
});
|
||||||
|
sequence('interrupt-confirmed-force-stop', () => {
|
||||||
|
const w = clone(f.world); assert.equal(evaluate(w, command(w, 'interrupt')), 'interrupt-fenced');
|
||||||
|
const id = confirmation(w, 'force-stop');
|
||||||
|
assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: id })), 'force-stop-fenced');
|
||||||
|
assert.equal(w.binding.admission, 'closed'); assert.equal(w.binding.state, 'stopping');
|
||||||
|
assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: id })), bad('confirmation'));
|
||||||
|
});
|
||||||
|
sequence('controller-revoked-recovery-control-recover', () => {
|
||||||
|
const w = clone(f.world); const id = confirmation(w, 'force-stop');
|
||||||
|
assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: id })), 'force-stop-fenced');
|
||||||
|
const s = w.stops.at(-1); const p = trustedProof(w, 'cohortProof', s.id), e = trustedProof(w, 'effectReport', s.id);
|
||||||
|
assert.equal(server(w, 'confirm-stopped', { proof: p, effects: e }), 'stopped');
|
||||||
|
server(w, 'revoke-connection', { connection: 'connection-1' });
|
||||||
|
assert.equal(w.binding.controllerConnection, null);
|
||||||
|
assert.equal(evaluate(w, command(w, 'recover', 'connection-2')), bad('controller'));
|
||||||
|
const recoveryControl = confirmation(w, 'acquire-recovery-control', 'connection-2');
|
||||||
|
assert.equal(evaluate(w, command(w, 'acquire-recovery-control', 'connection-2', { confirmation: recoveryControl })), 'recovery-control-acquired');
|
||||||
|
const resume = confirmation(w, 'recover', 'connection-2');
|
||||||
|
assert.equal(evaluate(w, command(w, 'recover', 'connection-2', { stop: s.id, confirmation: resume })), 'recovery-eligible');
|
||||||
|
assert.equal(w.outbox.length, 0); assert.equal(w.binding.admission, 'closed');
|
||||||
|
});
|
||||||
|
sequence('takeover-old-controller-new-approval', () => {
|
||||||
|
const w = clone(f.world); assert.equal(evaluate(w, command(w, 'takeover', 'connection-2')), 'transferred');
|
||||||
|
assert.equal(w.connections[0].mode, 'observer'); assert.equal(w.queue[0].actor, 'actor-1');
|
||||||
|
assert.equal(evaluate(w, command(w, 'prompt', 'connection-1')), bad('controller'));
|
||||||
|
const projection = w.approvals.at(-1); assert.notEqual(projection.id, 'approval-1');
|
||||||
|
assert.equal(evaluate(w, command(w, 'approval', 'connection-2', { approval: projection.id, choice: 'deny' })), 'approval-resolved');
|
||||||
|
assert.ok(w.decisions[0].resolvedBy); assert.equal(w.requests.at(-1).actor, 'actor-1');
|
||||||
|
assert.equal(evaluate(w, command(w, 'approval', 'connection-2', { approval: 'approval-1' })), bad('decision-completed'));
|
||||||
|
});
|
||||||
|
sequence('observer-draft-upload-send', () => {
|
||||||
|
const w = clone(f.world); const c = 'connection-2';
|
||||||
|
assert.equal(evaluate(w, command(w, 'create-draft', c)), 'draft-created'); const d = w.drafts.at(-1);
|
||||||
|
assert.equal(evaluate(w, command(w, 'begin-upload', c)), 'upload-receiving');
|
||||||
|
assert.equal(evaluate(w, command(w, 'append-upload', c)), 'upload-appended');
|
||||||
|
assert.equal(evaluate(w, command(w, 'complete-upload', c)), 'upload-staged');
|
||||||
|
assert.equal(evaluate(w, command(w, 'update-draft', c, { draft: d.id, revision: 1, text: 'hello file', attachments: ['upload-new-1'] })), 'draft-updated');
|
||||||
|
assert.equal(w.outbox.length, 0);
|
||||||
|
assert.equal(evaluate(w, command(w, 'list-drafts', c)), 'drafts-listed');
|
||||||
|
assert.ok(w.lastPrivateListing.some(x => x.id === d.id));
|
||||||
|
assert.equal(evaluate(w, command(w, 'takeover', c)), 'transferred');
|
||||||
|
const r = command(w, 'prompt', c, { draft: d.id, draftRevision: 2 });
|
||||||
|
assert.equal(evaluate(w, r), 'queued'); assert.equal(w.outbox.length, 0);
|
||||||
|
assert.equal(dispatch(w, w.queue.at(-1).id), 'dispatched'); assert.equal(w.outbox.length, 1);
|
||||||
|
});
|
||||||
|
sequence('duplicate-native-decision', () => {
|
||||||
|
const w = clone(f.world); assert.equal(evaluate(w, command(w, 'approval')), 'approval-resolved');
|
||||||
|
assert.equal(evaluate(w, command(w, 'approval')), bad('decision-completed')); assert.equal(w.outbox.length, 1);
|
||||||
|
});
|
||||||
|
sequence('second-launch-active-uncertain-stopping', () => {
|
||||||
|
const w = clone(f.world); assert.equal(admitBinding(w, w.binding), bad('already-active'));
|
||||||
|
for (const state of ['uncertain', 'stopping']) { w.bindings[0].state = state; assert.equal(admitBinding(w, w.binding), bad('unsafe-replacement')); }
|
||||||
|
});
|
||||||
|
{ // Asserted companion disposition only; NOT a model/lifecycle test.
|
||||||
|
// No invented delivery authority. Each remains a blocked companion-contract path.
|
||||||
|
const disposition = source => ['board-reply', 'tmux-paste', 'native-terminal', 'engine-queue', 'raw-rpc', 'launcher'].includes(source) ? bad('unmediated-ingress') : bad('unknown-ingress');
|
||||||
|
for (const source of ['board-reply', 'tmux-paste', 'native-terminal', 'engine-queue', 'raw-rpc', 'launcher']) assert.equal(disposition(source), bad('unmediated-ingress'));
|
||||||
|
}
|
||||||
|
sequence('stop-lifecycle-queue-draft-links', () => {
|
||||||
|
const w = clone(f.world); const id = confirmation(w, 'force-stop');
|
||||||
|
assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: id })), 'force-stop-fenced');
|
||||||
|
const s = w.stops.at(-1); assert.deepEqual(s.queueDrafts, [w.queue[0].recoveredDraft]); assert.equal(w.decisions[0].state, 'uncertain');
|
||||||
|
assert.equal(server(w, 'advance-stop', { state: 'cancelling' }), 'cancelling');
|
||||||
|
assert.equal(server(w, 'advance-stop', { state: 'stopping' }), 'stopping');
|
||||||
|
assert.equal(server(w, 'advance-stop', { state: 'uncertain' }), 'uncertain');
|
||||||
|
assert.equal(server(w, 'confirm-stopped', { proof: 'forged', effects: 'forged' }), bad('stop-proof'));
|
||||||
|
const p = trustedProof(w, 'cohortProof', s.id), e = trustedProof(w, 'effectReport', s.id);
|
||||||
|
assert.equal(server(w, 'confirm-stopped', { proof: p, effects: e }), 'stopped'); assert.equal(w.binding.state, 'stopped');
|
||||||
|
});
|
||||||
|
sequence('confirmation-reconnect-reconfirm', () => {
|
||||||
|
const w = clone(f.world); const id = confirmation(w, 'force-stop'); w.connections[0].generation++;
|
||||||
|
assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: id })), bad('confirmation'));
|
||||||
|
const newId = confirmation(w, 'force-stop'); assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: newId })), 'force-stop-fenced');
|
||||||
|
});
|
||||||
|
// Per-item cancellation must not cancel a neighbour. Bulk recovery must not
|
||||||
|
// partially mutate earlier items before discovering a corrupt later snapshot.
|
||||||
|
{
|
||||||
|
const w = clone(f.world);
|
||||||
|
w.queue.push({ ...clone(w.queue[0]), id: 'queue-neighbour' });
|
||||||
|
assert.equal(evaluate(w, command(w, 'cancel-queued')), 'recovered-as-draft');
|
||||||
|
assert.equal(w.queue[1].state, 'queued');
|
||||||
|
const broken = clone(f.world);
|
||||||
|
broken.queue.push({ ...clone(broken.queue[0]), id: 'queue-broken', frozenPayload: 'missing' });
|
||||||
|
const before = clone({ queue: broken.queue, drafts: broken.drafts });
|
||||||
|
const recovered = recoverQueue(broken);
|
||||||
|
assert.equal(recovered.drafts.length, 1); assert.deepEqual(recovered.failed, ['queue-broken']);
|
||||||
|
assert.equal(broken.queue[0].state, 'recovered-as-draft');
|
||||||
|
assert.equal(broken.queue[1].state, 'recovery-failed');
|
||||||
|
assert.equal(broken.drafts.length, before.drafts.length + 1);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const w = clone(f.world), original = clone(w.requests[0]);
|
||||||
|
assert.equal(evaluate(w, command(w, 'edit-queued')), 'queue-edited');
|
||||||
|
assert.deepEqual(w.requests[0], original);
|
||||||
|
assert.notEqual(w.queue[0].request, original.id);
|
||||||
|
assert.equal(dispatch(w, w.queue[0].id), 'dispatched');
|
||||||
|
}
|
||||||
|
// Public bootstrap never includes the private binding. Availability intersects
|
||||||
|
// authenticated grants, verified operations and current control/fence state.
|
||||||
|
function publicView(w, connection) {
|
||||||
|
const r = command(w, 'observe', connection, { cursor: null });
|
||||||
|
if (evaluate(w, r) !== 'observing') return null;
|
||||||
|
const c = w.connections.find(c => c.id === connection), g = w.grants.find(g => g.id === c.grant);
|
||||||
|
const v = clone(f.records.find(r => r.kind === 'clientView'));
|
||||||
|
v.connection = c.id; v.target = targetOf(w); v.controlMode = c.mode; v.revision = (w.viewRevision ?? 0) + 1; w.viewRevision = v.revision;
|
||||||
|
v.operations = f.commands.map(cmd => {
|
||||||
|
const op = cmd.operation;
|
||||||
|
let reason = !g.capabilities.includes(caps[op]) ? 'capability' : !w.verifiedOperations.includes(op) ? 'unsupported-capability' : null;
|
||||||
|
if (!reason && ['prompt', 'edit-queued', 'cancel-queued', 'approval', 'interrupt', 'force-stop', 'recover'].includes(op) && c.id !== w.binding.controllerConnection) reason = 'controller';
|
||||||
|
if (!reason && ['prompt', 'edit-queued', 'cancel-queued', 'approval', 'interrupt', 'takeover'].includes(op) && w.binding.admission !== 'open') reason = 'fenced';
|
||||||
|
if (!reason && op === 'takeover' && c.id === w.binding.controllerConnection) reason = 'already-controller';
|
||||||
|
if (!reason && op === 'force-stop' && !['active', 'stopping', 'uncertain'].includes(w.binding.state)) reason = 'fenced';
|
||||||
|
if (!reason && op === 'recover' && w.binding.state !== 'stopped') reason = 'stop-proof';
|
||||||
|
if (!reason && op === 'acquire-recovery-control' && (w.binding.admission !== 'closed' || w.connections.some(c => c.id === w.binding.controllerConnection && c.state === 'connected'))) reason = 'controller-present';
|
||||||
|
return { operation: op, enabled: !reason, reason };
|
||||||
|
});
|
||||||
|
// Synthetic trusted limits, not a native capability evidence producer.
|
||||||
|
v.limits = clone(w.nativeLimits);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const w = clone(f.world), before = publicView(w, 'connection-2');
|
||||||
|
assert.ok(before.operations.find(o => o.operation === 'takeover').enabled);
|
||||||
|
assert.equal(before.operations.find(o => o.operation === 'prompt').reason, 'controller');
|
||||||
|
assert.equal('cohortRef' in before, false); assert.equal('sourceRootRef' in before, false);
|
||||||
|
assert.equal(evaluate(w, command(w, 'takeover', 'connection-2')), 'transferred');
|
||||||
|
const after = publicView(w, 'connection-2');
|
||||||
|
assert.equal(after.target.controllerGeneration, before.target.controllerGeneration + 1);
|
||||||
|
assert.ok(after.revision > before.revision);
|
||||||
|
w.verifiedOperations = ['observe'];
|
||||||
|
assert.equal(publicView(w, 'connection-2').operations.find(o => o.operation === 'prompt').reason, 'unsupported-capability');
|
||||||
|
}
|
||||||
|
function consumeFinalParts(page, events) {
|
||||||
|
assert.equal(page.hasMore, page.nextCursor !== null);
|
||||||
|
const parts = new Map(page.entries.map(e => [`${e.message}:${e.part}`, clone(e)]));
|
||||||
|
const metadata = new Map(page.entries.map(e => [e.message, { role: e.role, entry: e.id }]));
|
||||||
|
const seen = new Map(); let sequence = page.throughSequence;
|
||||||
|
for (const e of events) {
|
||||||
|
assert.equal(e.streamEpoch, page.streamEpoch, 'reconcile epoch');
|
||||||
|
assert.equal(e.target.conversation, page.conversation); assert.equal(e.target.branch, page.branch);
|
||||||
|
const previous = seen.get(e.id);
|
||||||
|
if (previous) { assert.deepEqual(e, previous, 'conflicting event'); continue; }
|
||||||
|
seen.set(e.id, clone(e));
|
||||||
|
const key = `${e.message}:${e.part}`;
|
||||||
|
assert.ok(['user', 'assistant', 'tool', 'compaction', 'notice'].includes(e.role), 'missing role');
|
||||||
|
const identity = { role: e.role, entry: e.entry };
|
||||||
|
if (metadata.has(e.message)) assert.deepEqual(identity, metadata.get(e.message), 'conflicting message attribution');
|
||||||
|
else metadata.set(e.message, identity);
|
||||||
|
if (e.sequence <= page.throughSequence) {
|
||||||
|
if (e.type === 'message-end' && parts.has(key)) assert.deepEqual(parts.get(key).content, e.content, 'snapshot overlap conflict');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
assert.equal(e.sequence, sequence + 1, 'reconcile gap'); sequence = e.sequence;
|
||||||
|
assert.equal(e.type, 'message-end'); assert.equal(e.updateMode, 'replace');
|
||||||
|
assert.ok(e.entry && e.message && Number.isInteger(e.part));
|
||||||
|
parts.set(key, { id: e.entry, message: e.message, role: e.role, part: e.part, lastPart: e.lastPart, content: clone(e.content) });
|
||||||
|
}
|
||||||
|
const messages = new Map();
|
||||||
|
for (const [message, identity] of metadata) {
|
||||||
|
const ordered = [...parts.values()].filter(p => p.message === message).sort((a, b) => a.part - b.part);
|
||||||
|
const blocks = new Map();
|
||||||
|
ordered.forEach((p, i) => {
|
||||||
|
assert.equal(p.part, i, 'missing final part');
|
||||||
|
assert.equal(p.id, ordered[0].id); assert.equal(p.message, ordered[0].message);
|
||||||
|
assert.equal(p.lastPart, i === ordered.length - 1, 'incomplete message');
|
||||||
|
for (const block of p.content) {
|
||||||
|
const fragments = blocks.get(block.block) ?? [];
|
||||||
|
assert.equal(block.fragment, fragments.length, 'missing block fragment');
|
||||||
|
if (fragments.length) { assert.equal(fragments.at(-1).lastFragment, false); assert.equal(block.type, fragments[0].type); assert.equal(block.call, fragments[0].call); }
|
||||||
|
fragments.push(block); blocks.set(block.block, fragments);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const fragments of blocks.values()) assert.equal(fragments.at(-1).lastFragment, true);
|
||||||
|
messages.set(message, { ...identity, blocks });
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
{
|
||||||
|
const [page, a, b] = f.streamRecords;
|
||||||
|
const blocks = consumeFinalParts(page, [a, b, b]).get(a.message).blocks;
|
||||||
|
assert.equal(blocks.get(0).map(b => b.text).join(''), 'hello world');
|
||||||
|
assert.equal(blocks.get(1)[0].call, 'call-1');
|
||||||
|
assert.throws(() => consumeFinalParts(page, [{ ...b, sequence: 12 }]), /reconcile gap/);
|
||||||
|
assert.throws(() => consumeFinalParts(page, [{ ...b, streamEpoch: 'restarted' }]), /reconcile epoch/);
|
||||||
|
assert.throws(() => consumeFinalParts(page, [b, { ...b, content: [] }]), /conflicting event/);
|
||||||
|
assert.throws(() => consumeFinalParts(page, [{ ...a, content: [] }, b]), /snapshot overlap conflict/);
|
||||||
|
}
|
||||||
|
function reseal(w, id) {
|
||||||
|
const p = w.proofs.find(p => p.id === id); p.verificationDigest = hash(without(p, 'verificationDigest')); w.trustedProofs[id] = p.verificationDigest;
|
||||||
|
}
|
||||||
|
sequence('corrupt-payload-control-and-cancel', () => {
|
||||||
|
for (const recovery of [false, true]) {
|
||||||
|
const w = clone(f.world); w.payloads = [];
|
||||||
|
if (recovery) server(w, 'revoke-connection', { connection: 'connection-1' });
|
||||||
|
const op = recovery ? 'acquire-recovery-control' : 'takeover';
|
||||||
|
const fields = recovery ? { confirmation: confirmation(w, op, 'connection-2') } : {};
|
||||||
|
assert.equal(evaluate(w, command(w, op, 'connection-2', fields)), recovery ? 'recovery-control-acquired' : 'transferred');
|
||||||
|
assert.equal(w.queue[0].state, 'recovery-failed'); assert.equal(w.queue[0].recoveredDraft, null);
|
||||||
|
if (recovery) assert.deepEqual(w.stops.at(-1).queueFailures, [w.queue[0].id]);
|
||||||
|
}
|
||||||
|
const w = clone(f.world); w.payloads = [];
|
||||||
|
assert.equal(evaluate(w, command(w, 'cancel-queued')), 'recovery-failed');
|
||||||
|
});
|
||||||
|
sequence('revocation-nondestructive-reconciliation', () => {
|
||||||
|
const w = clone(f.world); server(w, 'revoke-connection', { connection: 'connection-1' });
|
||||||
|
const s = w.stops.at(-1); assert.equal(s.mode, 'revocation');
|
||||||
|
const id = confirmation(w, 'acquire-recovery-control', 'connection-2');
|
||||||
|
assert.equal(evaluate(w, command(w, 'acquire-recovery-control', 'connection-2', { confirmation: id })), 'recovery-control-acquired');
|
||||||
|
assert.equal(evaluate(w, command(w, 'prompt', 'connection-2')), bad('fenced'));
|
||||||
|
trustedProof(w, 'effectReport', s.id); const proofId = trustedProof(w, 'turnProof', s.id), p = w.proofs.find(p => p.id === proofId);
|
||||||
|
Object.assign(p, { turnState: 'input-reconciled', approvalDisposition: 'pending-reprojected', decisionOutcomes: [], nativePending: ['old-input'] }); reseal(w, proofId);
|
||||||
|
assert.equal(server(w, 'reconcile-revocation', { proof: proofId }), bad('reconciliation'));
|
||||||
|
p.nativePending = []; reseal(w, proofId);
|
||||||
|
assert.equal(server(w, 'reconcile-revocation', { proof: proofId }), 'reconciled');
|
||||||
|
assert.equal(w.binding.state, 'active'); assert.equal(w.binding.admission, 'open');
|
||||||
|
assert.equal(w.decisions[0].state, 'pending'); assert.equal(w.outbox.length, 0);
|
||||||
|
assert.equal(evaluate(w, command(w, 'prompt', 'connection-2')), 'queued');
|
||||||
|
});
|
||||||
|
sequence('admitted-disconnect-dispatch-receipt', () => {
|
||||||
|
const w = clone(f.world), r = command(w, 'prompt');
|
||||||
|
assert.equal(evaluate(w, r), 'queued'); const q = w.queue.at(-1);
|
||||||
|
w.connections[0].state = 'disconnected'; w.authenticatedChannels = ['channel-2'];
|
||||||
|
assert.equal(evaluate(w, command(w, 'prompt')), bad('channel'));
|
||||||
|
assert.equal(dispatch(w, q.id), 'dispatched'); assert.equal(w.outbox.length, 1);
|
||||||
|
assert.equal(dispatch(w, q.id), bad('queue-race')); assert.equal(w.outbox.length, 1);
|
||||||
|
assert.equal(evaluate(w, { ...r, connection: 'connection-2' }), 'existing:dispatched');
|
||||||
|
});
|
||||||
|
sequence('retry-after-transfer-current-disposition', () => {
|
||||||
|
const w = clone(f.world), r = command(w, 'prompt'); assert.equal(evaluate(w, r), 'queued');
|
||||||
|
assert.equal(evaluate(w, command(w, 'takeover', 'connection-2')), 'transferred');
|
||||||
|
assert.equal(evaluate(w, { ...r, connection: 'connection-2' }), 'existing:recovered-as-draft');
|
||||||
|
});
|
||||||
|
sequence('native-resolution-requires-evidence', () => {
|
||||||
|
const w = clone(f.world); evaluate(w, command(w, 'interrupt')); const s = w.stops.at(-1);
|
||||||
|
trustedProof(w, 'effectReport', s.id); const id = trustedProof(w, 'turnProof', s.id), p = w.proofs.find(p => p.id === id);
|
||||||
|
p.approvalDisposition = 'resolved'; p.decisionOutcomes = []; reseal(w, id);
|
||||||
|
assert.equal(server(w, 'reconcile-interrupt', { proof: id }), bad('reconciliation'));
|
||||||
|
p.decisionOutcomes = [{ decision: 'decision-1', state: 'resolved-natively', chosen: null, nativeEvidence: 'native-resolution-1' }]; reseal(w, id);
|
||||||
|
assert.equal(server(w, 'reconcile-interrupt', { proof: id }), 'reconciled');
|
||||||
|
assert.equal(w.decisions[0].state, 'resolved-natively'); assert.equal(w.decisions[0].nativeEvidence, 'native-resolution-1');
|
||||||
|
});
|
||||||
|
sequence('confirmation-predecessor-and-supersession', () => {
|
||||||
|
const w = clone(f.world), old = confirmation(w, 'force-stop');
|
||||||
|
evaluate(w, command(w, 'interrupt')); const predecessor = w.stops.at(-1);
|
||||||
|
assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: old })), bad('confirmation'));
|
||||||
|
const id = confirmation(w, 'force-stop'); assert.equal(w.confirmations.at(-1).stop, predecessor.id);
|
||||||
|
assert.equal(evaluate(w, command(w, 'force-stop', 'connection-1', { confirmation: id })), 'force-stop-fenced');
|
||||||
|
assert.equal(predecessor.state, 'superseded');
|
||||||
|
});
|
||||||
|
sequence('different-actor-draft-privacy', () => {
|
||||||
|
const w = clone(f.world), g = { ...clone(w.grants[0]), id: 'grant-2', revision: 'grant-rev-2', actor: 'actor-2' };
|
||||||
|
w.grants.push(g); Object.assign(w.connections[1], { actor: g.actor, grant: g.id, grantRevision: g.revision });
|
||||||
|
assert.equal(evaluate(w, command(w, 'takeover', 'connection-2')), 'transferred');
|
||||||
|
assert.equal(evaluate(w, command(w, 'list-drafts', 'connection-2')), 'drafts-listed'); assert.deepEqual(w.lastPrivateListing, []);
|
||||||
|
assert.equal(evaluate(w, command(w, 'prompt', 'connection-2', { draft: w.queue[0].recoveredDraft, draftRevision: 1 })), bad('draft'));
|
||||||
|
});
|
||||||
|
sequence('empty-history-new-user-assistant-roles', () => {
|
||||||
|
const [page, user, assistant, overlapPage] = f.newMessageRecords;
|
||||||
|
const rows = consumeFinalParts(page, [user, assistant, assistant]);
|
||||||
|
assert.deepEqual([...rows.values()].map(r => r.role), ['user', 'assistant']);
|
||||||
|
assert.deepEqual(consumeFinalParts(overlapPage, [user, assistant]), rows);
|
||||||
|
assert.throws(() => consumeFinalParts(page, [user, assistant, { ...assistant, id: 'conflict-role', sequence: 3, role: 'user' }]), /conflicting message attribution/);
|
||||||
|
});
|
||||||
|
console.log('Additional regressions PASS: queue recovery, immutable queue edit, safe bootstrap and multipart snapshot/event reconstruction');
|
||||||
|
assert.deepEqual(results, f.sequences);
|
||||||
|
console.log(`CHAT-01 R3 PASS: ${f.shapeCases.length} shape cases, ${f.stateCases.length} reference cases, ${results.length} lifecycle sequences. All synthetic; runtime/authentication/protocol/cohort evidence producers NOT VERIFIED.`);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user