mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 18:31:34 +01:00
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
b3699eb78f
commit
90dc06f5d2
4 files changed
+903
-2
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
**Document status:** Approved Phase 4 UX input (PAP-16712, approved by the board 2026-08-07)<br>
|
||||
**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.<br>
|
||||
**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.<br>
|
||||
**Scope:** desktop board (1440×900) — task page, issues board, attention inbox. Mobile is deferred by the source ticket.<br>
|
||||
**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.
|
||||
|
||||
|
||||
@@ -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(/(?<!\\)\|/).length;
|
||||
if (expected === null) {
|
||||
expected = columns;
|
||||
tables += 1;
|
||||
} else if (columns !== expected) {
|
||||
throw new Error(`ragged table row at section offset line ${index + 1}: ${columns} columns, expected ${expected}`);
|
||||
}
|
||||
});
|
||||
if (inFence) throw new Error("unbalanced code fence in section 18.12");
|
||||
return `${tables} tables`;
|
||||
});
|
||||
|
||||
// 10. Every degraded state names what must not be rendered.
|
||||
check("degraded-state table forbids a manufactured state per row", () => {
|
||||
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(/(?<!\\)\|/);
|
||||
return cells.length !== 3 || cells[2].trim().length === 0;
|
||||
});
|
||||
if (emptyForbid.length) throw new Error(`${emptyForbid.length} row(s) have no "must not" clause`);
|
||||
return `${rows.length} rows`;
|
||||
});
|
||||
|
||||
for (const line of checks) console.log(line);
|
||||
for (const line of failures) console.error(line);
|
||||
console.log(`\n${checks.length} passed, ${failures.length} failed`);
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
Reference in new issue
Block a user