From 90dc06f5d283db1b5a42c0ead13ccac7d084d743 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 7 Aug 2026 16:06:00 +0000 Subject: [PATCH] docs(runner): specify operator and reviewer read models Phase 4 of the 18.3 status authority track (PAP-16713). Adds spec section 18.12, the binding contract between the arbitration/attention contracts (18.3-18.11) and the approved operator UX reference. - Ten presentation invariants (OPX-1..OPX-10) stated as testable criteria. - Persistence prerequisites: attention_requests, attention_request_attempts, and attention_budgets, which 18.3.1 described but 18.7 never defined, plus the index the board summary needs. - Read models: NativeOutcomeLayers carries all four authority layers as one embedded object, plus StatusDecisionView, RunFinalizationView, AttentionRequestView, and the compact list/board summaries. - livePathIntegrity makes "every waiting state names an owner" a server assertion rather than a UI convention. - Adds the missing attention read routes; specifies envelope asOf, authorization, and a whitelist redaction table. - Client derivation rules, component data contracts, a requirement -> field -> component -> gate coverage matrix, degraded/legacy/error renderings, and gates OPX-F1..OPX-F11. pnpm check:runner-phase4-spec enforces reason-code copy totality, read-model coverage, gate reachability, invariant enforcement, route agreement, backing persistence, and the claim copy law across both documents. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 1 + .../paperclip-native-runner-spike-spec.md | 663 +++++++++++++++++- .../paperclip-runner-status-attention-ux.md | 1 + scripts/check-runner-phase4-spec.mjs | 240 +++++++ 4 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 scripts/check-runner-phase4-spec.mjs diff --git a/package.json b/package.json index 37eeae0cccb..4bdc6f08a81 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "check:tokens": "node scripts/check-forbidden-tokens.mjs", "check:token-gates": "node scripts/check-token-gates.mjs", "check:no-git-push": "node scripts/check-no-git-push.mjs", + "check:runner-phase4-spec": "node scripts/check-runner-phase4-spec.mjs", "test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs", "test:install-sh-docker": "./scripts/test-install-sh-docker.sh", "test:hermes-gateway-smoke": "node --test scripts/smoke/hermes-gateway-smoke.test.mjs", diff --git a/packages/paperclip-runner/spec/paperclip-native-runner-spike-spec.md b/packages/paperclip-runner/spec/paperclip-native-runner-spike-spec.md index e6c501536bc..638b78b353f 100644 --- a/packages/paperclip-runner/spec/paperclip-native-runner-spike-spec.md +++ b/packages/paperclip-runner/spec/paperclip-native-runner-spike-spec.md @@ -3381,7 +3381,8 @@ The approved operator-facing presentation of these four authorities — the layered outcome vocabulary, components, desktop board flows, reason-code copy, and token mapping — is maintained in [`paperclip-runner-status-attention-ux.md`](./paperclip-runner-status-attention-ux.md) -(PAP-16712) and is normative for Phase 4 UI work. +(PAP-16712) and is normative for Phase 4 UI work. Section 18.12 binds that +design to concrete read models, routes, derivation rules, and release gates. The arbiter consumes a durable assessment rather than raw prose: @@ -4162,6 +4163,7 @@ Read/API additions: - `GET /api/issues/:issueId/completion-contracts/current` and `/completion-contracts` return company-authorized current/history summaries; - `GET /api/issues/:issueId/status-decisions` returns assessment/decision summaries, disagreement reasons, supersession, and liveness references with sensitive payloads redacted; - `GET /api/heartbeat-runs/:runId/finalization` returns turn/run facts, coordinator phase, preserved result reference, assessment, decision, and retry-safe failure details; +- `GET /api/issues/:issueId/attention-requests` and `/attention-requests/:requestId` return canonical attention records with owner, derived authority, effective scope, attempt history, expiry, duplicate state, and response binding; suppressed duplicates never appear as top-level records; - existing issue and heartbeat-run responses gain compact `reportedWorkDisposition`, `latestStatusDecision`, and `nativeFinalization` summaries; - runner ingestion uses the authenticated native runner/session channel and its bound run/turn. No public request body may select `companyId`, prior status, policy version, effective resolver, reason code, or authoritative transition. @@ -4184,7 +4186,7 @@ Implementation-ready file map: | Heartbeat integration | In `server/src/services/heartbeat.ts`, replace the native-mode exit-code outcome branch and the later sequence of independent issue liveness handlers with the coordinator. Keep usage/cost/session/runtime diagnostics and the legacy branch intact. Workspace finalization completes before native run success and arbitration. | | Routes/OpenAPI | Add company-authorized read routes in `server/src/routes/issues.ts` and `server/src/routes/agents.ts`; document them in `server/src/routes/openapi.ts`. Native ingestion stays on the runner transport/internal service boundary. | | UI API | Add read types/fetchers in `ui/src/api/issues.ts` and `ui/src/api/heartbeats.ts`; invalidate issue, run, assessment, and decision queries from the existing live-update provider. | -| UI surfaces | Phase 4 renders run outcome separately from issue status, the agent claim separately from the arbiter decision, reason/evidence classification, supersession history, finalization failure, and the concrete current liveness owner/path. This phase supplies data contracts only; the approved operator UX for these surfaces is [`paperclip-runner-status-attention-ux.md`](./paperclip-runner-status-attention-ux.md), and the read models above MUST carry every field that design renders (turn/run/claim/decision layers, criterion assessments, side-effect links, attention owner/authority/scope/attempts/expiry, resume consequence, supersession chain). | +| UI surfaces | Phase 4 renders run outcome separately from issue status, the agent claim separately from the arbiter decision, reason/evidence classification, supersession history, finalization failure, and the concrete current liveness owner/path. This phase supplies data contracts only; the approved operator UX for these surfaces is [`paperclip-runner-status-attention-ux.md`](./paperclip-runner-status-attention-ux.md), and the read models above MUST carry every field that design renders (turn/run/claim/decision layers, criterion assessments, side-effect links, attention owner/authority/scope/attempts/expiry, resume consequence, supersession chain). Section 18.12 is the normative binding of those two documents: view types, read routes, derivation rules, component data contracts, and release gates. | | CLI/MCP | Expose read-only finalization/decision inspection if operator parity requires it. Do not expose status-decision creation or arbitrary replay inputs. | Migration and rollout sequence: @@ -4208,6 +4210,663 @@ Structural consistency gate before implementation handoff: It does not create an issue comment for every tool call or token delta. +### 18.12 Operator and reviewer read models and presentation contract + +Sections 18.3–18.11 define what Paperclip decides. The approved operator +design in +[`paperclip-runner-status-attention-ux.md`](./paperclip-runner-status-attention-ux.md) +defines how that decision must read on screen. This section is the binding +contract between them and is normative for the read routes, the view types, the +client derivation rules, the component data requirements, and the release gates +of section 18.3 Phase 4. Phase-numbering note: this is Phase 4 of the status +authority track (PAP-16713). It is unrelated to the Phase 4 driver milestone in +the implementation plan and to the interaction-bridge gates in section 27.9.1. + +Two rules govern everything below. + +1. **The server ships facts and codes; the UI ships language.** Every + authoritative value crosses the wire as a stable enum, identifier, integer, + or timestamp. Operator sentences are composed on the client from those codes. + The server never sends a rendered status sentence, and the UI never invents a + fact that has no field. +2. **Presentation never re-derives authority.** Eligibility to respond, resolver + selection, minimum authority, effective scope, resumability, and duplicate + status are computed server-side and read as booleans/enums. A client that + loses those fields degrades to read-only (section 18.12.12); it never guesses. + +#### 18.12.1 Operator presentation invariants + +These are enforceable review and test criteria, referenced as `[OPX-n]` +throughout this section and in section 18.12.13. + +| ID | Invariant | Failure it prevents | +|---|---|---| +| OPX-1 | Every finalized native run renders turn outcome, run outcome, agent claim, and authoritative issue status as four separately labeled values in the default (unexpanded) view. | The single-badge collapse that lets process success read as work completion. | +| OPX-2 | The agent claim is always attributed, past-tense, and visually non-authoritative (dashed outline + quote glyph, never a filled status color). The composed strings "agent succeeded", "agent failed", and "agent completed" never appear in copy constants or fixtures. | An unverified model claim reading as an organizational fact. | +| OPX-3 | Any decision whose `toStatus` is non-terminal renders at least one named live path with an owner and a link. If the server reports no live path and no finalization error, the UI renders the recovery treatment, never a silent waiting state. | Invisible dead tasks that appear to be "in progress". | +| OPX-4 | A board-routed attention card renders owner, derived authority, effective scope, attempt/budget position, expiry, duplicate state, and the resume consequence *before* the response control. | Answering blind; not knowing what the button fires. | +| OPX-5 | Suppressed duplicates never create an inbox row, a notification, or a second card. Non-board routes never render a human response affordance. | Alarm fatigue and spurious escalation, the parent issue's core complaint. | +| OPX-6 | Finalization, reconciliation, and schema-rejection errors preserve and display the original claim, keep issue status unchanged, and name a recovery owner plus retry position. | Manufactured `blocked`/`in_review`, or a silently discarded work report. | +| OPX-7 | Model-authored prose is only ever rendered quoted, attributed, length-capped, and as plain text. It never occupies a label, chip, title, tone, link target, or status position. | Copy spoofing: a model writing text that imitates an arbiter verdict. | +| OPX-8 | Every read is authorized from the source issue/run company; cross-company and hidden references produce the same generic not-found and render nothing. | Cross-tenant disclosure through an operator surface. | +| OPX-9 | Countdowns, relative times, and expiry tones derive from the response envelope's `asOf`, not the client clock. | Skewed clocks showing a live request as expired, or the reverse. | +| OPX-10 | No new raw color, spacing, or shadow values. Every tone pairs color with a distinct glyph, and no state is conveyed by color alone. | Token drift and color-only signaling. | + +#### 18.12.2 Persistence prerequisites for the operator read model + +Section 18.3.1 defines the canonical attention record and section 18.7 defines +the contract/result/assessment/decision tables, but no table currently persists +attention records, their attempt history, or their budgets. The operator surface +cannot render owner, attempts, expiry, or duplicate state without them, so +Phase 4 requires these additions alongside the section 18.7 set. + +```text +attention_requests + id uuid primary key + company_id uuid not null references companies + issue_id uuid not null references issues on delete cascade + run_id uuid null references heartbeat_runs on delete set null + turn_id text null + version integer not null + completion_contract_id uuid not null references completion_contracts + classification text not null + canonical_action text null + canonical_resource_ref text null + required_expertise text[] not null default '{}' + requested_authority text null -- untrusted caller hint, display-only + minimum_authority text not null -- policy-derived + requested_scope text not null + effective_scope text not null + blocking_current_turn boolean not null + alternate_track_ref text null + request_fingerprint text not null + equivalence_fingerprint text not null + material_evidence_digest text not null + urgency text not null + state text not null + selected_route text null + selected_resolver_json jsonb null -- resolved target class + id, no secrets + route_ref text null + request_json jsonb not null -- canonical record, redacted at read + expires_at timestamptz not null + resolved_at timestamptz null + policy_version text not null + supersedes_request_id uuid null references attention_requests + canonical_request_id uuid null references attention_requests -- set on suppressed duplicates + created_at timestamptz not null + updated_at timestamptz not null + unique (company_id, id) + unique (company_id, request_fingerprint, version) + index (company_id, issue_id, state, selected_route) + index (company_id, equivalence_fingerprint) + +attention_request_attempts + id uuid primary key + company_id uuid not null references companies + request_id uuid not null references attention_requests on delete cascade + ordinal integer not null + route text not null -- context | retry | agent | board | external | recovery + outcome text not null -- resolved | failed | exhausted | skipped | routed + reason_code text not null -- AttentionResolutionReasonCode + resolver_ref text null + occurred_at timestamptz not null + unique (request_id, ordinal) + +attention_budgets + company_id uuid not null references companies + equivalence_fingerprint text not null + context_passes integer not null default 0 + transient_retries integer not null default 0 + distinct_agent_resolvers integer not null default 0 + human_or_external_routes integer not null default 0 + resolution_transitions integer not null default 0 + emitted_wakes integer not null default 0 + first_attempt_at timestamptz not null + last_attempt_at timestamptz not null + policy_version text not null + primary key (company_id, equivalence_fingerprint) +``` + +`attention_request_attempts` is append-only and is the sole source of the +attempt history the operator sees; the UI never reconstructs history from +activity prose. `attention_budgets` is the shared counter set of section 18.3.3, +so a suppressed duplicate increments the family it joined rather than starting a +new one. Suppressed duplicates persist as rows with `canonical_request_id` set +and never appear as top-level records in any read route `[OPX-5]`. + +`native_run_finalizations.failure_detail` additionally carries the redacted +recovery owner reference and the retry schedule, because section 18.12.4 renders +both `[OPX-6]`. + +#### 18.12.3 Layered outcome read model + +One embedded object carries the four authority layers of section 18.3. Every +surface that shows any layer embeds this whole object, which is what makes +`[OPX-1]` checkable rather than aspirational. + +```ts +interface NativeOutcomeLayers { + schema: "paperclip.native-outcome-layers.v1"; + runId: string; + turnId: string | null; + + // Layer 1 — harness driver authority. + turnTerminalState: TurnTerminalState | null; + + // Layer 2 — runner/finalizer authority. + runTerminalState: RunTerminalState | null; + + // Layer 3 — model work-report authority. Never a status. + claim: { + reportedWorkDisposition: ReportedWorkDisposition | null; + reportedAt: string | null; + // Server-resolved from the run's execution agent, not from the report body. + claimedBy: { kind: "agent" | "user"; ref: string; displayName: string } | null; + // Optional model prose, plain text, server-truncated to 1,000 characters. + quotedSummary: string | null; + quotedSummaryTruncated: boolean; + // Present when the report failed validation but was preserved (18.7). + schemaStatus: "accepted" | "rejected" | "partial"; + preservedResultRef: string | null; + } | null; + + // Layer 4 — arbiter authority. The only authoritative status on this object. + status: { + current: AuthoritativeIssueStatus; + statusVersion: number; + latestDecisionId: string | null; + }; + + // Compatibility marker. Legacy runs set false and render the existing path. + native: boolean; +} +``` + +Rules: + +- `claim.quotedSummary` is the only free-text field on this object and is + governed by `[OPX-7]`: rendered as plain text with a quote glyph, never + markdown, never linkified, never used as a card title. +- A null `claim` means the run produced no report. It is not a failure and must + not render as one. +- `native: false` freezes the surface to today's rendering; no chip row, decision + card, or attention card is added to a legacy run (section 28.2). + +#### 18.12.4 Status decision and finalization read models + +```ts +interface StatusDecisionView { + schema: "paperclip.status-decision-view.v1"; + id: string; + issueId: string; + runId: string | null; + outcome: NativeOutcomeLayers; + + fromStatus: AuthoritativeIssueStatus; + toStatus: AuthoritativeIssueStatus; + transitionApplied: boolean; + reasonCode: StatusDecisionReasonCode; + applicationState: "proposed" | "applied" | "superseded" | "rejected"; + decidedAt: string; + + trigger: { + kind: "runner_finalizer" | "board_user" | "authorized_agent" | "interaction" | "dependency" | "monitor"; + actorRef: string | null; + actorDisplayName: string | null; + }; + + // Rendered as the criterion table. Descriptions come from the immutable + // contract snapshot, never from the model report. + criterionAssessments: Array<{ + criterionId: string; + description: string; + required: boolean; + assessmentMode: "mechanical" | "policy_backed_agent_claim" | "named_reviewer" | "board"; + claimStatus: "satisfied" | "not_satisfied" | "unknown" | null; + outcome: "accepted" | "missing" | "rejected" | "unverifiable"; + reasonCode: string; + evidence: EvidenceRefView[]; + }>; + + missingRequirements: string[]; + pendingGovernedActions: Array<{ + kind: "approval" | "security_review" | "qa_review" | "board_decision"; + ownerRef: string | null; + ownerDisplayName: string | null; + targetRef: string | null; + }>; + remainingWork: Array<{ description: string; blocksCompletion: boolean }>; + + // "What happens next". Required non-empty whenever the decision is + // non-terminal and no finalizationError is present [OPX-3]. + livePaths: LivePathView[]; + sideEffects: Array<{ + kind: StatusDecisionEffectKind; + target: TargetRefView | null; + deliveryState: "pending" | "delivered" | "failed" | "cancelled"; + }>; + + // Truthfulness escape hatch. "missing" forces the recovery treatment. + livePathIntegrity: "satisfied" | "not_required" | "missing"; + + supersession: { + supersedesDecisionId: string | null; + supersededByDecisionId: string | null; + }; + + audit: { + reasonCode: StatusDecisionReasonCode; + decisionId: string; + policyVersion: string; + completionContractRevision: string; + decisionVersion: number; + }; +} + +interface LivePathView { + kind: "continuation" | "retry" | "delegated_issue" | "interaction" | "review" | "monitor" | "blocker" | "recovery"; + ref: string; + owner: { kind: "agent" | "user" | "system"; ref: string; displayName: string }; + target: TargetRefView | null; + // Rendered by the client as the "who owns the next move" sentence. + expectedBy: string | null; +} + +interface TargetRefView { + type: "issue" | "interaction" | "approval" | "run" | "work_product" | "document" | "monitor" | "agent" | "user"; + id: string; + identifier: string | null; // e.g. "PAP-16713" + displayName: string | null; +} + +interface EvidenceRefView { + ref: string; + kind: "test" | "artifact" | "diff" | "observation" | "external_check"; + description: string; // from the authoritative evidence record + status: "passed" | "failed" | "unknown"; + target: TargetRefView | null; + // True when the model asserted this evidence but no authoritative record + // backs it. Rendered with the claim treatment, never as proof [OPX-7]. + claimOnly: boolean; +} + +interface RunFinalizationView { + schema: "paperclip.run-finalization-view.v1"; + runId: string; + issueId: string; + outcome: NativeOutcomeLayers; + phase: + | "observed" | "workspace_finalizing" | "ready_for_assessment" + | "arbitrating" | "committed" | "retryable_failure" | "terminal_failure"; + failureCode: string | null; // stable enum, never raw exception text + failureSummary: string | null; // redacted, non-secret, ≤ 240 chars + retry: { + attempt: number; + maxAttempts: number; + nextAttemptAt: string | null; + exhausted: boolean; + } | null; + recoveryOwner: { kind: "agent" | "user" | "system"; ref: string; displayName: string } | null; + preservedResultRef: string | null; + assessmentId: string | null; + decisionId: string | null; + // Status is unchanged by definition here; surfaced so the card can say so. + issueStatusUnchanged: true; +} +``` + +`StatusDecisionEffectKind` is the section 18.3 `sideEffects[].kind` enum. The +server never returns unredacted evidence payloads, tool arguments, secrets, or +cross-company references in any of these views (section 18.11). + +The `livePathIntegrity` field is what makes `[OPX-3]` enforceable at the +contract level rather than by inspection: the server itself declares whether the +"who owns the next move" answer exists. `missing` is only legal together with a +`prior_status_preserved_no_live_path`, `attention_budget_exhausted`, or +finalization-error reason code, and it renders as recovery, never as waiting. + +#### 18.12.5 Attention read model + +```ts +interface AttentionRequestView { + schema: "paperclip.attention-request-view.v1"; + id: string; + issueId: string; + runId: string | null; + version: number; + state: "pending" | "routed" | "resolved" | "expired" | "superseded" | "rejected" | "exhausted"; + classification: CanonicalAttentionRequest["classification"]; + urgency: "normal" | "high"; + createdAt: string; + + // Header copy. Server-normalized, plain text, ≤ 240 chars, model-authored → + // rendered under the claim rules [OPX-7]. + summary: string; + + // Owner — always a named resolver, never "someone" [OPX-4]. + route: "context" | "retry" | "agent" | "board" | "external" | "recovery" | null; + resolver: { kind: "agent" | "user" | "role" | "external_system" | "system"; ref: string; displayName: string } | null; + + authority: { + minimum: CanonicalAttentionRequest["minimumAuthority"]; // policy-derived, authoritative + requested: CanonicalAttentionRequest["minimumAuthority"] | null; // untrusted caller hint + corrected: boolean; // requested !== minimum → render the "asked → resolved as" pattern + }; + + scope: { + requested: "current_turn" | "current_track" | "task_wide"; + effective: "current_turn" | "current_track" | "task_wide"; + narrowed: boolean; + blockingCurrentTurn: boolean; + alternateTrack: TargetRefView | null; + }; + + attempts: { + used: number; // resolution transitions consumed + limit: number; // policy budget for the equivalence family + history: Array<{ + ordinal: number; + route: "context" | "retry" | "agent" | "board" | "external" | "recovery"; + outcome: "resolved" | "failed" | "exhausted" | "skipped" | "routed"; + reasonCode: AttentionResolutionReasonCode; + resolverDisplayName: string | null; + occurredAt: string; + }>; + }; + + expiry: { expiresAt: string; expired: boolean; remainingMs: number }; // remainingMs relative to envelope asOf [OPX-9] + + duplicates: { + suppressedCount: number; + recent: Array<{ requestId: string; createdAt: string; variant: "repeated" | "reworded" }>; + }; + + // Response affordance. Server-evaluated with the centralized addressee + // predicate of section 17.3.1; the client never computes eligibility [OPX-5]. + response: { + canRespondInline: boolean; + interaction: TargetRefView | null; + // Exact continuation consequence, derived server-side because only the + // server knows whether the provider session still advertises resumability. + resumeBinding: + | "resume_current_turn" + | "wake_assignee_next_turn" + | "agent_route_no_action" + | "external_route_no_action" + | "recovery_owner_action" + | "audit_only_superseded" + | "expired_fallback_sent"; + resumeSubject: { kind: "agent" | "user" | "system"; ref: string; displayName: string } | null; + }; + + delegatedIssue: TargetRefView | null; + supersedesRequestId: string | null; + supersededByRequestId: string | null; + policyVersion: string; +} +``` + +Rules: + +- Suppressed duplicates are never top-level `AttentionRequestView` records. They + appear only inside `duplicates` on their canonical request `[OPX-5]`. +- `response.canRespondInline` is `true` only when `route === "board"`, `state` is + `pending` or `routed`, the request is unexpired and non-superseded, and the + viewer passes the addressee predicate. Every other combination renders + read-only, including a board-routed request owned by a different person. +- `authority.requested` is display-only and is always labeled as what the agent + asked for. It is never used to select a route, a tone, or an affordance. +- `resumeBinding` values map one-to-one to the resume sentences in the UX + document; adding a value requires adding its sentence in the same change. + +#### 18.12.6 Compact summaries for list and board surfaces + +Board rows and the properties panel need bounded, batchable data. Existing issue +and heartbeat-run responses gain: + +```ts +interface IssueStatusAuthoritySummary { + schema: "paperclip.issue-status-authority-summary.v1"; + reportedWorkDisposition: ReportedWorkDisposition | null; + latestDecision: { + id: string; + reasonCode: StatusDecisionReasonCode; + toStatus: AuthoritativeIssueStatus; + transitionApplied: boolean; + decidedAt: string; + disagreesWithClaim: boolean; + } | null; + waitingOn: { + kind: "attention" | "review" | "approval" | "blocker" | "monitor" | "delegated_issue"; + owner: { kind: "agent" | "user" | "system"; ref: string; displayName: string }; + since: string; + } | null; + boardAttention: { pending: boolean; viewerIsResolver: boolean; ownerDisplayName: string | null }; + reconciliationPending: boolean; + native: boolean; +} + +interface HeartbeatRunNativeSummary { + schema: "paperclip.heartbeat-run-native-summary.v1"; + outcome: NativeOutcomeLayers; + finalizationPhase: RunFinalizationView["phase"] | null; + latestDecisionId: string | null; +} +``` + +The issues-board list route returns exactly the two chip conditions of the UX +document — `boardAttention` and `reconciliationPending` — plus `latestDecision` +and `waitingOn`. It adds no column and no per-row fan-out: summaries are +produced by one batched join on `issues.last_status_decision_id` and one grouped +query over `attention_requests` using the +`(company_id, issue_id, state, selected_route)` index. `boardAttention` is +viewer-scoped, which is what lets one chip slot render "Needs you" for the +resolver and "Needs {name}" for everyone else. + +#### 18.12.7 Read routes, authorization, and redaction + +Section 18.11 lists three read routes. The attention surface needs a fourth, +without which the approved `AttentionRequestCard` has no source. + +| Route | Returns | Ordering and limits | +|---|---|---| +| `GET /api/issues/:issueId/status-decisions` | `{ asOf, decisions: StatusDecisionView[], nextCursor }` | `decision_version` descending, default limit 20, maximum 100. Supports `runId` and `includeSuperseded` filters. | +| `GET /api/issues/:issueId/completion-contracts/current` and `/completion-contracts` | Current revision summary and revision history with criteria, gates, authority, and risk | History descending by revision, default limit 20. | +| `GET /api/heartbeat-runs/:runId/finalization` | `{ asOf, finalization: RunFinalizationView }` | Single record. `404` when the run is legacy or has no coordinator row. | +| `GET /api/issues/:issueId/attention-requests` **(new)** | `{ asOf, requests: AttentionRequestView[], nextCursor }` | Pending/routed first, then `created_at` descending. Default limit 25, maximum 100. Filters: `state`, `route`, `runId`. Suppressed duplicates are excluded as top-level rows. | +| `GET /api/issues/:issueId/attention-requests/:requestId` **(new)** | `{ asOf, request: AttentionRequestView }` | Deep-link target for inbox rows and activity references. | + +Envelope and authorization rules: + +- Every response carries `asOf` (server RFC 3339, millisecond precision). All + countdowns and relative times derive from it `[OPX-9]`. +- Company is derived from the parent issue/run binding. Cross-company, + hidden-resource, and unknown-ID requests return one generic not-found with no + timing or body differences `[OPX-8]`. +- These are read models only. No route accepts a status, reason code, policy + version, resolver, or authority field; there is no public arbiter-application + endpoint, and agent API keys cannot reach one (section 18.11). +- Responding to a board-routed attention request continues to use the existing + interaction/approval mutation identified by `response.interaction`. Phase 4 + adds no new mutation surface. + +Redaction is a whitelist, not a filter: + +| Never returned | Returned instead | +|---|---| +| Secret material, credential values, tool arguments | An authorized binding reference and its display name | +| Raw evidence payloads, command output, file contents | `EvidenceRefView` with description, status, and a company-scoped link | +| Request/equivalence fingerprints, input digests, canonical serializations | `decisionId`, `decisionVersion`, `policyVersion`, `completionContractRevision` for the audit footer | +| Cross-company agents, users, issues, integrations | Nothing; the reference is omitted and the parent read still succeeds | +| Raw exception text and stack traces | `failureCode` plus a redacted `failureSummary` ≤ 240 characters | +| Resolver inventory, capability scores, routing internals | The selected resolver and the attempt history only | + +#### 18.12.8 Live updates and cache invalidation + +Native surfaces reuse the existing live-update provider (section 23.2) and add +no polling. Published payloads carry identifiers and reason codes only, never +evidence or payload bodies (section 18.11). + +| Event | Carries | Invalidates | +|---|---|---| +| `status_decision.committed` | `issueId`, `decisionId`, `reasonCode`, `toStatus`, `transitionApplied` | Issue detail, issue list row, `status-decisions`, run finalization | +| `status_decision.superseded` | `issueId`, `decisionId`, `supersededByDecisionId` | `status-decisions` | +| `native_finalization.updated` | `runId`, `issueId`, `phase`, `failureCode` | Run finalization, issue list row | +| `attention_request.updated` | `issueId`, `requestId`, `state`, `route`, `version` | `attention-requests`, attention inbox, issue list row | +| `attention_request.suppressed` | `issueId`, `canonicalRequestId`, `suppressedCount` | Canonical request only. Emits no notification and creates no inbox row `[OPX-5]` | + +Query keys are `["issue", issueId, "status-decisions"]`, +`["issue", issueId, "attention-requests"]`, and +`["run", runId, "finalization"]`. A resolved attention response patches the +existing shared interaction card in place (section 23.2 `[UX-5]`) rather than +appending a second card. + +#### 18.12.9 Client derivation rules + +These are pure functions in `ui/src/lib/native-status/`, unit-tested +independently of rendering. They are the entire licensed surface for turning +codes into language. + +| Function | Rule | +|---|---| +| `decisionVerb(view)` | `transitionApplied ? "Moved to " + statusLabel(toStatus) : "Kept " + statusLabel(fromStatus)`. No other verb form is permitted. | +| `reasonCopy(reasonCode)` | Lookup in `STATUS_DECISION_REASON_COPY`, a total map over the section 18.3 enum. Unknown code returns `{ text: "Decision recorded — open details", tone: "preserved" }` and the audit footer shows the raw code. Never blank, never a guessed tone. | +| `decisionTone(reasonCode)` | Lookup in the decision tone map of the UX document §5 item 1 (`accepted`, `review`, `progress`, `downgraded`, `recovery`, `preserved`, `blocked`). Tone is a function of the reason code only — never of the model report, the run outcome, or free text. | +| `toneGlyph(tone)` | Total map to distinct lucide glyphs so no tone is color-only `[OPX-10]`. | +| `claimLabel(claim)` | `"{displayName} reported {disposition}"`, past tense, plus relative time from `asOf`. Returns null when `claim` is null. Banned-string lint applies `[OPX-2]`. | +| `livePathSentence(path)` | `"{ownerDisplayName} owns the next move"` specialized per `kind`, always with the target link. Called only when `livePathIntegrity !== "missing"` `[OPX-3]`. | +| `resumeSentence(view)` | Total map over `response.resumeBinding` to the UX document's resume lines, interpolating `resumeSubject.displayName`. A missing subject falls back to the role noun, never to "someone" `[OPX-4]`. | +| `expiryTone(expiry, totalWindowMs)` | Neutral above 25% remaining; amber below; expired renders inert text plus a clock glyph. Uses `remainingMs` from the envelope `[OPX-9]`. | +| `authorityCorrection(authority)` | Renders `asked: {requested} → resolved as: {minimum}` only when `corrected`; otherwise renders `minimum` alone. | +| `scopeCorrection(scope)` | Renders the struck requested scope and the effective scope only when `narrowed`, with the alternate track link when present. | +| `attentionChip(summary)` | `viewerIsResolver ? "Needs you" : "Needs " + ownerDisplayName`. Suppressed duplicates and non-board routes return null. | + +A reason code added to the protocol enum without a `STATUS_DECISION_REASON_COPY` +row fails the totality test in section 18.12.13; this is the mechanism behind +the UX document's rule that new reason codes cannot reach a UI surface +uncaptioned. + +#### 18.12.10 Component data contracts and surface map + +Components live under `ui/src/components/native-status/`, refining the UX +document's `ui/src/components/` placement to match the section 23.1 convention. +Each is registered in `/design-guide`. + +| Component | File | Source | Required fields | Notes | +|---|---|---|---|---| +| `OutcomeChipRow` | `native-status/OutcomeChipRow.tsx` | `NativeOutcomeLayers` | `turnTerminalState`, `runTerminalState`, `claim.reportedWorkDisposition`, `claim.claimedBy`, `status.current` | Renders all four layers unexpanded `[OPX-1]`. Run/turn failure uses red text, never a red fill. | +| `StatusDecisionCard` | `native-status/StatusDecisionCard.tsx` | `StatusDecisionView` | all of §18.12.4 | Collapsed row + expanded claim/decision strip, criterion table, live paths, audit footer. | +| `AttentionRequestCard` | `native-status/AttentionRequestCard.tsx` | `AttentionRequestView` | all of §18.12.5 | Response control renders only when `canRespondInline`. | +| `FinalizationErrorCard` | `native-status/FinalizationErrorCard.tsx` | `RunFinalizationView` | `phase`, `failureCode`, `retry`, `recoveryOwner`, `outcome.claim`, `issueStatusUnchanged` | Recovery tone; claim stays visible `[OPX-6]`. | +| `ClaimChip` | `native-status/ClaimChip.tsx` | `NativeOutcomeLayers["claim"]` | disposition, attribution, time | The named "claim chip" pattern; reused anywhere a model asserts an unverified value. | +| `IssueRunLedger` (extend) | existing | `HeartbeatRunNativeSummary` | `outcome`, `latestDecisionId` | Native runs gain the chip row and nested decision card; `native: false` renders exactly as today. | +| `AttentionQueueRow` (extend) | existing | `AttentionRequestView` | owner, authority, expiry, `canRespondInline` | New source kind for board-routed pending requests only. | +| `IssueDetail` properties panel (extend) | existing | `IssueStatusAuthoritySummary` | `latestDecision`, `waitingOn` | Two rows: "Decision" and "Waiting on". No new panel. | +| Issues board row (extend) | existing | `IssueStatusAuthoritySummary` | `boardAttention`, `reconciliationPending` | One secondary chip slot, two conditions, no new column. | + +`AttentionInteractionResolver` and `DecisionTriageStrip` are reused unchanged; +Phase 4 adds no second resolver implementation (section 23.2 `[UX-5]`). + +#### 18.12.11 Requirement coverage matrix + +Every requirement of the approved UX document maps to a field and a component. +This matrix is the structural gate for implementation handoff. + +| UX requirement | Read-model field(s) | Component | Gate | +|---|---|---|---| +| Turn outcome distinct from status | `outcome.turnTerminalState` | `OutcomeChipRow` | OPX-F1 | +| Run outcome distinct from status | `outcome.runTerminalState` | `OutcomeChipRow` | OPX-F1 | +| Agent claim distinct and attributed | `outcome.claim.reportedWorkDisposition`, `claim.claimedBy`, `claim.reportedAt` | `ClaimChip` | OPX-F1, OPX-F7 | +| Authoritative status unchanged in meaning | `outcome.status.current` | existing `StatusBadge` | OPX-F1 | +| Decision verb and reason sentence | `transitionApplied`, `fromStatus`, `toStatus`, `reasonCode` | `StatusDecisionCard` | OPX-F2 | +| Claim-vs-decision strip on disagreement | `outcome.claim`, `toStatus`, `reasonCode` | `StatusDecisionCard` | OPX-F2 | +| Criterion table with classifications | `criterionAssessments[]` | `StatusDecisionCard` | OPX-F2 | +| Evidence gaps | `missingRequirements`, `criterionAssessments[].outcome`, `EvidenceRefView.claimOnly` | `StatusDecisionCard` | OPX-F2 | +| Pending governed actions | `pendingGovernedActions[]` | `StatusDecisionCard` | OPX-F2 | +| "What happens next" side effects as links | `livePaths[]`, `sideEffects[]` | `StatusDecisionCard` | OPX-F2, OPX-F6 | +| Audit footer and supersession chain | `audit`, `supersession` | `StatusDecisionCard` | OPX-F2 | +| Attention owner | `resolver`, `route` | `AttentionRequestCard` | OPX-F3 | +| Attention authority incl. correction | `authority.minimum`, `authority.requested`, `authority.corrected` | `AttentionRequestCard` | OPX-F3, OPX-F4 | +| Attention scope incl. narrowing | `scope.*` | `AttentionRequestCard` | OPX-F3 | +| Attempts and budget position | `attempts.used`, `attempts.limit`, `attempts.history[]` | `AttentionRequestCard` | OPX-F3, OPX-F4 | +| Expiry countdown and expired state | `expiry.*`, envelope `asOf` | `AttentionRequestCard` | OPX-F3, OPX-F8 | +| Deduplication state | `duplicates.suppressedCount`, `duplicates.recent[]` | `AttentionRequestCard` | OPX-F5 | +| Resolver route determines affordances | `response.canRespondInline`, `route` | `AttentionRequestCard` | OPX-F4 | +| Exact continuation action | `response.resumeBinding`, `response.resumeSubject` | `AttentionRequestCard` | OPX-F3 | +| Delegated issue link | `delegatedIssue` | `AttentionRequestCard` | OPX-F4 | +| Reconciliation/finalization error | `RunFinalizationView.*` | `FinalizationErrorCard` | OPX-F6 | +| Status unchanged during error | `issueStatusUnchanged`, `outcome.status.current` | `FinalizationErrorCard` | OPX-F6 | +| Recovery owner and retry position | `recoveryOwner`, `retry.*` | `FinalizationErrorCard` | OPX-F6 | +| Board `Needs you` chip | `boardAttention.*` | issues board row | OPX-F3, OPX-F5 | +| Board `Reconciliation` chip | `reconciliationPending` | issues board row | OPX-F6 | +| Properties "Decision" and "Waiting on" | `latestDecision`, `waitingOn` | `IssueDetail` | OPX-F2, OPX-F3 | +| Legacy runs unchanged | `native: false` | `IssueRunLedger` | OPX-F9 | +| Reason-code copy completeness | `STATUS_DECISION_REASON_COPY` | derivation layer | OPX-F7 | +| Company scoping | route authorization | all | OPX-F10 | +| Token and accessibility rules | none (styling) | all | OPX-F11 | + +#### 18.12.12 Degraded, legacy, and error states + +The failure mode this phase exists to prevent is a UI that invents a state. Each +row below is a required rendering, not a suggestion. + +| Condition | Renders | Must not | +|---|---|---| +| Read route loading | Skeleton in the card slot; the authoritative `StatusBadge` renders immediately from the issue record | Block the status badge behind decision data | +| Read route fails | Inline "Decision details unavailable — retry" with a retry control | Imply `blocked`, `in_review`, or completion; hide the claim | +| Legacy run (`native: false`) | Today's ledger rendering, unchanged | Add chips, decision cards, or attention cards | +| Native run, no decision yet | Chip row with the layers that exist; "Decision pending finalization" | Show a decision that has not committed | +| Unknown reason code | Generic caption, `preserved` tone, raw code in the audit footer | Blank text or a guessed tone | +| `livePathIntegrity: "missing"` | Recovery treatment naming the recovery owner and the operational alert | A waiting state with no owner `[OPX-3]` | +| Attention expired | Inert card, "expired unanswered · one fallback wake sent" | Any active response control | +| Attention exhausted | Card with full attempt history and the recovery owner | A synthesized human ask or a `blocked` chip `[OPX-6]` | +| Board-routed request owned by another person | Read-only card, "Waiting on {name}" | A response control for a non-resolver `[OPX-5]` | +| Agent- or external-routed request | Read-only card with the delegated target | Any human affordance `[OPX-5]` | +| Suppressed duplicate | One collapsed link row under the canonical card | A second card, inbox row, or notification `[OPX-5]` | +| Superseded decision | Collapsed, with links in both directions of the chain | Deletion or silent replacement | +| Cross-company or hidden reference | Reference omitted; parent view still renders | A broken link or a disclosing error | +| Board user overrides status manually | New superseding decision card with `trigger.kind: "board_user"`; prior card shows the chain | Presenting the override as the arbiter's own conclusion | + +#### 18.12.13 Phase 4 verification gates + +Fixtures are table-driven against the production components and the shared +query cache; static mockups do not satisfy any gate (section 23.10). + +| Gate | Coverage | Non-negotiable assertion | +|---|---|---| +| OPX-F1 — layer separation | Every row of the section 18.5 terminal conversion table rendered through `OutcomeChipRow` | All four layers are present and separately labeled in the unexpanded DOM; the claim node never carries a status-fill class `[OPX-1] [OPX-2]` | +| OPX-F2 — decision rendering | One fixture per `StatusDecisionReasonCode` | Correct verb, caption, tone, glyph, criterion rows, audit footer; disagreement fixtures render the claim-vs-decision strip | +| OPX-F3 — attention completeness | Board-routed pending request | Owner, authority, scope, attempts, expiry, duplicates, and resume sentence all appear above the response control in DOM order `[OPX-4]` | +| OPX-F4 — route affordances | `context`, `retry`, `agent`, `board`, `external`, `recovery` routes; board request owned by another user; authority-corrected request | Only the eligible board case exposes a response control; the correction pattern renders exactly when `corrected` `[OPX-5]` | +| OPX-F5 — duplicate suppression | Canonical request plus repeated and reworded duplicates | Exactly one card, one inbox row, zero notifications, incremented visible counter `[OPX-5]` | +| OPX-F6 — error truthfulness | `finalization_failed_claim_preserved`, `result_schema_rejected`, `attention_budget_exhausted`, `prior_status_preserved_no_live_path`, `livePathIntegrity: "missing"` | Status badge value is unchanged from the fixture's prior status; claim still visible; recovery owner and retry position named; no `blocked` or `in_review` node `[OPX-6]` | +| OPX-F7 — copy law | Static scan of `ui/src/**` copy constants, fixtures, and rendered fixture DOM | Zero matches for `/agent (succeeded\|failed\|completed)/i`; `STATUS_DECISION_REASON_COPY` is total over the protocol enum; every entry ≤ 70 characters and contains no agent name `[OPX-2] [OPX-7]` | +| OPX-F8 — clock independence | Fixture with client clock skewed ±6 hours | Countdown and relative times match the envelope `asOf`, not the client clock `[OPX-9]` | +| OPX-F9 — legacy compatibility | Legacy run in the same ledger as a native run | Legacy row DOM is byte-identical to the pre-change snapshot | +| OPX-F10 — company scoping | Cross-company issue, run, decision, and attention IDs | One generic not-found for each; no identifier, name, or timing disclosure `[OPX-8]` | +| OPX-F11 — token and accessibility gate | `pnpm check:tokens`, `pnpm check:token-gates`, axe pass over all new components at 1440×900 | No new raw color/spacing/shadow values; every tone has a distinct glyph; AA contrast in light and dark; reduced-motion has no pulse `[OPX-10]` | + +Ahead of any implementation, `pnpm check:runner-phase4-spec` is the standing +structural gate over the two documents themselves. It proves reason-code copy +totality, read-model coverage of every operator field, gate reachability from +the coverage matrix, invariant enforcement, route agreement between sections +18.11 and 18.12, backing persistence for the attention read model, and the +copy law of `[OPX-2]`. A new reason code, component field, or gate that breaks +any of those relationships fails the check before it can reach a UI surface. + +Screenshot matrix (adds to section 23.10, all at 1440×900 desktop; mobile is +deferred by the source ticket): flows A–E of the UX document, the board row with +each chip, the properties panel rows, the legacy-run ledger, the expired and +exhausted attention states, and the superseded-decision chain. The artifact +index names the fixture, the flow, the reason code, the route, and the gate it +satisfies. + +Section 27.6 gains these UI cases: layered outcome rendering, decision card per +reason code, criterion table classification, attention card completeness, route +affordance matrix, duplicate suppression, finalization error truthfulness, +unknown reason code fallback, clock-skew countdown, legacy fallback, and +keyboard operation of the response control. + --- ## 19. Sandbox and provider integration diff --git a/packages/paperclip-runner/spec/paperclip-runner-status-attention-ux.md b/packages/paperclip-runner/spec/paperclip-runner-status-attention-ux.md index 46ff49117d5..13372ac1859 100644 --- a/packages/paperclip-runner/spec/paperclip-runner-status-attention-ux.md +++ b/packages/paperclip-runner/spec/paperclip-runner-status-attention-ux.md @@ -2,6 +2,7 @@ **Document status:** Approved Phase 4 UX input (PAP-16712, approved by the board 2026-08-07)
**Relationship to the spike specification:** [`paperclip-native-runner-spike-spec.md`](./paperclip-native-runner-spike-spec.md) §18.3–§18.11 remains the normative status-authority, arbitration, and attention-resolution contract. This document is the normative operator-facing presentation of that contract: vocabulary, components, board flows, copy, and token mapping. Phase 4 data contracts (spec §18.11) MUST satisfy the read requirements here; UI implementation tasks consume this document directly.
+**Phase 4 binding:** spec §18.12 (PAP-16713) is the contract that joins the two documents — view types, read routes, client derivation rules, component data contracts, and the `OPX-1…OPX-10` presentation invariants with their `OPX-F1…OPX-F11` release gates. Every requirement in this document appears as a row in the §18.12.11 coverage matrix with its backing field, component, and gate. Implementation reads both documents; a requirement here with no §18.12 field is a spec bug, not an implementation decision.
**Scope:** desktop board (1440×900) — task page, issues board, attention inbox. Mobile is deferred by the source ticket.
**Mockups:** six flow screenshots and one inspectable single-file HTML mockup (per-flow rendering via `?only=a|b|c|d|e|board`) are attached to PAP-16712. diff --git a/scripts/check-runner-phase4-spec.mjs b/scripts/check-runner-phase4-spec.mjs new file mode 100644 index 00000000000..2cdbe54402c --- /dev/null +++ b/scripts/check-runner-phase4-spec.mjs @@ -0,0 +1,240 @@ +#!/usr/bin/env node +// Structural consistency check for the 18.3 Phase 4 operator/reviewer contract +// (spec section 18.12) against the approved operator UX reference. +// +// Usage: pnpm check:runner-phase4-spec +// +// This is a spec-level gate, not a runtime test. It proves that the Phase 4 +// presentation contract, the arbitration contract it renders, and the approved +// UX document stay in agreement. + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const specDir = join(dirname(fileURLToPath(import.meta.url)), "..", "packages", "paperclip-runner", "spec"); +const spec = readFileSync(join(specDir, "paperclip-native-runner-spike-spec.md"), "utf8"); +const ux = readFileSync(join(specDir, "paperclip-runner-status-attention-ux.md"), "utf8"); + +const failures = []; +const checks = []; + +function check(name, fn) { + try { + const detail = fn(); + checks.push(`PASS ${name}${detail ? ` — ${detail}` : ""}`); + } catch (error) { + failures.push(`FAIL ${name} — ${error.message}`); + } +} + +function section(source, heading) { + const start = source.indexOf(heading); + if (start === -1) throw new Error(`missing heading: ${heading}`); + const level = heading.match(/^#+/)[0].length; + const rest = source.slice(start + heading.length); + const next = rest.search(new RegExp(`\\n#{1,${level}} `)); + return next === -1 ? rest : rest.slice(0, next); +} + +const phase4 = section(spec, "### 18.12 Operator and reviewer read models and presentation contract"); + +// 1. Reason-code enum totality across spec, UX copy table, and Phase 4 gates. +check("reason-code enum has UX copy for every value", () => { + const enumBlock = spec.match(/`StatusDecisionReasonCode` is a stable protocol enum[\s\S]*?```text\n([\s\S]*?)```/); + if (!enumBlock) throw new Error("could not locate the reason-code enum block"); + const codes = enumBlock[1].trim().split("\n").map((line) => line.trim()).filter(Boolean); + if (codes.length < 20) throw new Error(`enum looks truncated (${codes.length} codes)`); + const copyTable = section(ux, "## 4. Reason-code copy table (complete v1 enum)"); + const missing = codes.filter((code) => { + if (copyTable.includes(code)) return false; + // The UX table folds the two cancellation scopes into one row. + const folded = code.replace(/^cancellation_(turn|run)_only$/, "cancellation_turn_only / _run_only"); + return !copyTable.includes(folded) && !copyTable.includes(code.replace("cancellation_", "_")); + }); + if (missing.length) throw new Error(`no operator copy for: ${missing.join(", ")}`); + return `${codes.length} codes covered`; +}); + +// 2. Every operator field the UX document requires has a Phase 4 read-model field. +check("attention card required fields exist in the read model", () => { + const attention = section(spec, "#### 18.12.5 Attention read model"); + const required = { + owner: /\bresolver\??:/, + "resolver route": /\broute\??:\s*"context"/, + "derived authority": /minimum:\s*CanonicalAttentionRequest\["minimumAuthority"\]/, + "requested authority": /requested:\s*CanonicalAttentionRequest\["minimumAuthority"\]/, + "authority correction": /corrected:\s*boolean/, + "effective scope": /effective:\s*"current_turn"/, + "scope narrowing": /narrowed:\s*boolean/, + attempts: /attempts:\s*\{/, + "attempt history": /history:\s*Array<\{/, + expiry: /expiry:\s*\{[^}]*expiresAt/, + "deduplication state": /duplicates:\s*\{[^}]*suppressedCount/, + "inline response eligibility": /canRespondInline:\s*boolean/, + "continuation action": /resumeBinding:/, + "delegated issue": /delegatedIssue:/, + supersession: /supersededByRequestId:/, + }; + const missing = Object.entries(required) + .filter(([, pattern]) => !pattern.test(attention)) + .map(([label]) => label); + if (missing.length) throw new Error(`missing field(s) for: ${missing.join(", ")}`); + return `${Object.keys(required).length} required fields present`; +}); + +check("decision card required fields exist in the read model", () => { + const decision = section(spec, "#### 18.12.4 Status decision and finalization read models"); + const required = { + "four outcome layers": /outcome:\s*NativeOutcomeLayers/, + "decision verb inputs": /transitionApplied:\s*boolean/, + "reason code": /reasonCode:\s*StatusDecisionReasonCode/, + "criterion assessments": /criterionAssessments:\s*Array<\{/, + "evidence gaps": /missingRequirements:\s*string\[\]/, + "pending governed actions": /pendingGovernedActions:\s*Array<\{/, + "live paths": /livePaths:\s*LivePathView\[\]/, + "live path integrity": /livePathIntegrity:/, + "side effects": /sideEffects:\s*Array<\{/, + supersession: /supersededByDecisionId:/, + "audit footer": /audit:\s*\{/, + "finalization phase": /phase:\s*\n?\s*\|?\s*"observed"/, + "retry position": /retry:\s*\{[^}]*maxAttempts/, + "recovery owner": /recoveryOwner:/, + "status unchanged marker": /issueStatusUnchanged:\s*true/, + }; + const missing = Object.entries(required) + .filter(([, pattern]) => !pattern.test(decision)) + .map(([label]) => label); + if (missing.length) throw new Error(`missing field(s) for: ${missing.join(", ")}`); + return `${Object.keys(required).length} required fields present`; +}); + +// 3. Coverage matrix references only gates that are actually defined. +check("coverage matrix gates are all defined", () => { + const matrix = section(spec, "#### 18.12.11 Requirement coverage matrix"); + const gatesSection = section(spec, "#### 18.12.13 Phase 4 verification gates"); + const referenced = new Set([...matrix.matchAll(/OPX-F\d+/g)].map((m) => m[0])); + const defined = new Set([...gatesSection.matchAll(/\|\s*(OPX-F\d+)\s*—/g)].map((m) => m[1])); + if (referenced.size === 0) throw new Error("coverage matrix references no gates"); + const undefinedGates = [...referenced].filter((gate) => !defined.has(gate)); + if (undefinedGates.length) throw new Error(`undefined gate(s): ${undefinedGates.join(", ")}`); + const unusedGates = [...defined].filter((gate) => !referenced.has(gate)); + if (unusedGates.length > 1) throw new Error(`gate(s) not reached by any matrix row: ${unusedGates.join(", ")}`); + return `${defined.size} gates, ${referenced.size} referenced`; +}); + +// 4. Every presentation invariant is enforced somewhere, not just declared. +check("every OPX invariant is referenced outside its definition", () => { + const invariants = section(spec, "#### 18.12.1 Operator presentation invariants"); + const defined = [...invariants.matchAll(/\|\s*(OPX-\d+)\s*\|/g)].map((m) => m[1]); + if (defined.length !== 10) throw new Error(`expected 10 invariants, found ${defined.length}`); + const body = phase4.replace(invariants, ""); + const orphans = defined.filter((id) => !new RegExp(`${id}\\b`).test(body)); + if (orphans.length) throw new Error(`declared but never enforced: ${orphans.join(", ")}`); + return `${defined.length} invariants enforced`; +}); + +// 5. Every UX-document flow reaches a degraded/error rendering rule or a gate. +check("UX flows A-E are all covered by Phase 4 gates", () => { + const gates = section(spec, "#### 18.12.13 Phase 4 verification gates"); + const flowSubjects = { + "A completion disagreement": /disagreement/i, + "B human attention": /attention completeness/i, + "C agent routing": /route affordances/i, + "D duplicate suppression": /duplicate suppression/i, + "E finalization error": /error truthfulness/i, + }; + const missing = Object.entries(flowSubjects) + .filter(([, pattern]) => !pattern.test(gates)) + .map(([label]) => label); + if (missing.length) throw new Error(`no gate covers flow(s): ${missing.join(", ")}`); + return "5 flows covered"; +}); + +// 6. Read routes named in 18.11 and 18.12 agree. +check("read routes agree between 18.11 and 18.12", () => { + const changeMap = section(spec, "### 18.11 API, shared-contract, UI, and implementation change map"); + const routes = section(spec, "#### 18.12.7 Read routes, authorization, and redaction"); + const required = [ + "/status-decisions", + "/completion-contracts", + "/finalization", + "/attention-requests", + ]; + const missing = required.filter((route) => !changeMap.includes(route) || !routes.includes(route)); + if (missing.length) throw new Error(`route(s) not present in both sections: ${missing.join(", ")}`); + return `${required.length} routes aligned`; +}); + +// 7. Persistence prerequisites cover every table the read models select from. +check("attention read model has backing persistence", () => { + const persistence = section(spec, "#### 18.12.2 Persistence prerequisites for the operator read model"); + const tables = ["attention_requests", "attention_request_attempts", "attention_budgets"]; + // Match the DDL declaration line exactly so a renamed table is a failure, not + // a substring hit on a longer name. + const missing = tables.filter((table) => !new RegExp(`^${table}\\s*$`, "m").test(persistence)); + if (missing.length) throw new Error(`missing table definition(s): ${missing.join(", ")}`); + if (!persistence.includes("(company_id, issue_id, state, selected_route)")) { + throw new Error("board-summary index is not specified"); + } + return `${tables.length} tables + summary index`; +}); + +// 8. Copy law: the banned composed strings appear only as prohibitions. +check("banned claim strings appear only in prohibitions", () => { + const banned = /agent (succeeded|failed|completed)/gi; + for (const [label, source] of [["spec 18.12", phase4], ["ux doc", ux]]) { + for (const match of source.matchAll(banned)) { + const context = source.slice(Math.max(0, match.index - 160), match.index + 60); + const prohibited = /never|banned|not\b|nowhere|zero matches|anti-copy|prohibit|forbid|\blie\b/i.test(context); + if (!prohibited) throw new Error(`${label} uses "${match[0]}" outside a prohibition`); + } + } + return "no affirmative uses"; +}); + +// 9. Markdown tables in the new section are rectangular. +check("18.12 markdown tables are well formed", () => { + const lines = phase4.split("\n"); + let inFence = false; + let expected = null; + let tables = 0; + lines.forEach((line, index) => { + if (line.trimStart().startsWith("```")) inFence = !inFence; + if (inFence) return; + const isRow = line.trimStart().startsWith("|") && line.trimEnd().endsWith("|"); + if (!isRow) { + expected = null; + return; + } + const columns = line.trim().slice(1, -1).split(/(? { + const degraded = section(spec, "#### 18.12.12 Degraded, legacy, and error states"); + const rows = degraded + .split("\n") + .filter((line) => line.startsWith("|") && !line.includes("---") && !line.startsWith("| Condition")); + if (rows.length < 12) throw new Error(`expected at least 12 degraded-state rows, found ${rows.length}`); + const emptyForbid = rows.filter((row) => { + const cells = row.trim().slice(1, -1).split(/(?