From 5ae130838d985329daaef4cbe13ca23f12b7d223 Mon Sep 17 00:00:00 2001 From: kasra Date: Mon, 27 Jul 2026 15:19:19 +0000 Subject: [PATCH 1/8] docs(design): unify owner chat+docs+board into goal-directed surface Design-first backlog for the owner-experience artifact: one relationship (Talk/Know/Watch) bound to a project outcome, earned autonomy, honest receipts-by-id. Locks invariants in owner-experience/v1 + pure helpers/tests. Names unmet deps (Tier-2 BLOCK, Port-4 dormant, ModelPort v2). No route/UI. Co-authored-by: Cursor --- docs/owner-experience-v1.json | 115 ++++++ ...owner-experience-unified-surface-design.md | 328 ++++++++++++++++++ src/owner/owner-experience-contract.ts | 274 +++++++++++++++ tests/owner-experience-contract.test.ts | 248 +++++++++++++ 4 files changed, 965 insertions(+) create mode 100644 docs/owner-experience-v1.json create mode 100644 docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md create mode 100644 src/owner/owner-experience-contract.ts create mode 100644 tests/owner-experience-contract.test.ts diff --git a/docs/owner-experience-v1.json b/docs/owner-experience-v1.json new file mode 100644 index 00000000..0fa59772 --- /dev/null +++ b/docs/owner-experience-v1.json @@ -0,0 +1,115 @@ +{ + "id": "owner-experience/v1", + "status": "design", + "spec": "docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md", + "thesis": "Owner owns the goal and the gate; the mubot owns execution and adaptation; one relationship with Talk, Know, and Watch facets bound to a single project outcome.", + "artifactRef": "4cb8d84f-b5b9-4a55-a47c-3af193161b19", + "facets": ["talk", "know", "watch"], + "loop": [ + "goal", + "sense", + "rank", + "act", + "receipt", + "learn", + "adapt", + "gate" + ], + "principles": [ + "one-face", + "goals-not-tasks", + "honest-by-construction", + "earned-autonomy", + "visible-learning", + "owner-owns-goals-and-gates" + ], + "invariants": [ + "owner-owns-goal-mubot-cannot-redefine", + "owner-owns-gate-mubot-cannot-self-verdict", + "receipts-resolved-by-id-not-label", + "progress-never-fake-green", + "earned-autonomy-one-step-with-verified-wins", + "lesson-requires-receipt-id", + "talk-v1-tier1-mubot-only", + "learn-adapt-instinct-requires-port4-prerequisite", + "brain-remains-rank-not-act" + ], + "talk": { + "v1Runtime": "tier1_persistent_mubot", + "tier2Status": "dyad-gate-blocked", + "tier2AllowedOnlyAfter": "tier2-user-chat/v1-pass" + }, + "trustLevels": [ + { "level": 0, "autonomy": "suggest" }, + { "level": 1, "autonomy": "draft" }, + { "level": 2, "autonomy": "execute_with_approval" }, + { "level": 3, "autonomy": "execute" } + ], + "earnedAutonomy": { + "maxStepsPerWiden": 1, + "defaultRequiredWins": 3, + "winVerification": "resolved_by_id", + "decider": "owner_or_admin_human", + "mubotSelfWiden": false + }, + "progress": { + "allowedKinds": ["measured", "unmeasured", "unavailable"], + "forbidFakeGreen": true, + "unwiredSource": "unmeasured" + }, + "unmetDependencies": [ + { + "id": "tier2-user-chat", + "status": "dyad-gate-blocked", + "blocks": ["talk-facet-every-member"] + }, + { + "id": "model-port-v2-tool-calling", + "status": "not-built", + "blocks": ["tier2-tool-loops"] + }, + { + "id": "pot-monthly-model-ledger", + "status": "display-only", + "blocks": ["honest-talk-budget-story"] + }, + { + "id": "per-project-docs-ui", + "status": "design-only", + "blocks": ["know-facet-full-editor"] + }, + { + "id": "port-4-instinct-memory", + "status": "dormant-branch", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "learning-ranker", + "status": "under-dyad-gate", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "structured-project-outcome", + "status": "not-on-projects-table", + "blocks": ["outcome-strip"] + } + ], + "nonGoals": [ + "route-in-this-design-commit", + "migration-applied-in-this-design-commit", + "ui-in-this-design-commit", + "merge-port4-or-ranker-from-this-worktree", + "pretend-modelport-v2-exists", + "cite-dormant-surfaces-as-reuse" + ], + "buildSlices": [ + "outcome-model", + "owner-home-shell", + "trust-strip-gate-queue", + "earned-autonomy", + "visible-learning", + "talk-facet-tier2-after-pass", + "know-facet-docs-507", + "learn-adapt-instinct-after-port4" + ] +} diff --git a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md new file mode 100644 index 00000000..2eb200cb --- /dev/null +++ b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md @@ -0,0 +1,328 @@ +# Owner Experience — Unify Chat + Docs + Board into One Goal-Directed Surface + +**Status:** Design, drafted 2026-07-27. Design + contract only — no route, no +migration, no UI, no ModelPort change. Awaiting dyad-gate (Kasra-core +correctness + diverse second-eye) before any build slice starts. Unassigned = +backlog, not active until pulled. + +**Thesis owner:** Hadi, 2026-07-23 mubot/owner-experience session — artifact +`4cb8d84f-b5b9-4a55-a47c-3af193161b19` (*"Your mupot gets better at your +business every week"*). The felt product an owner lives: declare an OUTCOME → +mubot drives the loop → owner sees goal-progress + earned-trust + what it +learned. + +**Builds on:** owner-experience artifact (2026-07-23), +[project-centered workspace](./2026-07-17-project-centered-workspace-design.md), +[project lifecycle](./2026-07-23-project-lifecycle-control-loop-design.md) (PR #500), +[BYOA harness matrix](./2026-07-23-byoa-harness-support-matrix-design.md) (PR #503), +[per-project docs RBAC](./2026-07-23-per-project-docs-rbac-design.md) (PR #507 / #522–526), +Tier-2 chat design (`2026-07-27-tier2-stateless-user-chat-design.md`, currently +dyad-gate **BLOCK**), Tier-1 kayhermes panel (PR #505), `ProjectSituation` +(`src/projects/situation.ts`), agent `Autonomy` (`src/types.ts`), +`workflow_receipts` + completion/lessons receipts, BrainPort v1 (rank-only). + +**Machine contract:** [`docs/owner-experience-v1.json`](../../owner-experience-v1.json) ++ pure TS [`src/owner/owner-experience-contract.ts`](../../../src/owner/owner-experience-contract.ts). + +## 0. Problem + +Today an owner meets **three separate products** that happen to share a pot: + +| Surface | Intent | Current state (verified 2026-07-27 on this worktree) | +|---|---|---| +| **Chat** (talk to mubot) | Intent in, clarification out | Tier-1 kayhermes panel in review (#505). Tier-2 every-member chat is **design-blocked** (ModelPort has no tools; pot monthly budget display-only; result fan-in unsettled). | +| **Docs** (read/edit knowledge) | Shared memory the mubot acts on | Spec #507 drafted; **no dashboard Docs surface** on main. `project_remember` / `project_recall` exist as MCP. | +| **Board** (watch it work) | Living picture of work | **Wired:** project detail + `ProjectSituation` (health, blockers, reviews, `next_action`), lifecycle loop (start-gate / circuit-breaker / completion / stall). | + +Missing is the **relationship**, not another panel: one goal-directed home where +the owner declares an OUTCOME (north-star metric), the mubot drives Sense → Rank +→ Act → Receipt → Learn → Adapt, and the owner always sees **goal-progress + +earned-trust level + what the mubot learned** — honest (receipts, not +fake-green), with trust that widens only on verified wins. + +## 1. Thesis (one line) + +**Owner owns the goal and the gate; the mubot owns execution and adaptation; +the surface is one relationship with three facets — Talk, Know, Watch — +bound to a single project outcome.** + +``` + ┌────────── owner home (per project) ──────────┐ + │ OUTCOME strip (north-star + progress truth) │ + │ TRUST strip (autonomy + what needs a yes) │ + ├──────────┬──────────────┬────────────────────┤ + │ Talk │ Know │ Watch │ + │ (chat) │ (docs) │ (board/situation) │ + └────┬─────┴──────┬───────┴──────────┬─────────┘ + │ │ │ + ▼ ▼ ▼ + intent / Q&A same memory store situation + tasks + │ │ │ + └────────────┴────────┬─────────┘ + ▼ + closed loop (mubot drives; owner gates) + Goal → Sense → Rank → Act → Receipt → Learn → Adapt ↺ + │ + ▼ + Gate (owner holds) +``` + +## 2. Seven moments (product arc — from the artifact) + +These are acceptance *feelings*, not build slices. Every slice must preserve them. + +1. **Declare an outcome** — not a task list. Structured north-star + how it is measured. +2. **Meet the mubot** — one teammate restates the goal, proposes a first plan, asks before acting. +3. **Work where you can see it** — one living picture: goal, progress, now, blocked, next. +4. **Learn from what didn't work** — misses are receipted and visible; hidden misses cannot teach. +5. **Adapt toward the goal** — dead ends die; verified movers get doubled down. +6. **Trust compounds** — autonomy widens only with verified wins (earned, never blanket). +7. **Owner always owns goal + gate** — mubot never redefines the target or self-signs risky work. + +## 3. Substrate inventory (existence ≠ enforcement) + +### 3.1 Wired on main — reuse, do not rebuild + +| Piece | Where | Role in owner-experience | +|---|---|---| +| Project row + free-text `goal` | `projects` / `src/projects/service.ts` | Carrier for outcome text today; must grow a **structured** north-star (slice 1) | +| `ProjectSituation` + `next_action` | `src/projects/situation.ts` | Watch facet truth (health, blockers, reviews, flights) | +| Project lifecycle loop | `src/projects/loop.ts`, start-gate, circuit-breaker, completion-gate, stall | Drive/finish guarantees behind Watch | +| Board tasks + flights | `src/tasks/*`, `src/flight/*` | Act surface; chat may only dispatch here | +| `workflow_receipts` + verdicts + evidence projections | `writeReceiptToD1`, `src/projects/projections.ts` | Honest win/miss store (resolve by **receipt id**, never free-string label) | +| `lessons_capture` receipt | `src/projects/completion-gate.ts` | First visible-learning feed (project terminal) | +| Agent `Autonomy` enum | `src/types.ts` `suggest\|draft\|execute\|execute_with_approval` | Trust strip vocabulary — **static dial today** | +| `autonomyImpliesGate` | `src/org/service.ts` | Gate implication already enforced on task create | +| BrainPort v1 (rank-only) | `src/types.ts` `BrainPort` | Rank step must stay rank-not-act | +| Agent KPI helpers | `src/agents/kpi-sources.ts` (`task_counter`, `github_prs`) | Pattern for outcome measurement — **agent-scoped, not project-owner UX** | +| Project memory MCP | `project_remember` / `project_recall` / `project_context` | Know facet store (same store docs must use — #507) | +| `grant_agent_capability` | `src/mcp/provision.ts` | Manual capability path; earned-autonomy proposes, owner confirms | + +### 3.2 Explicit unmet dependencies (not cited as reuse) + +| Dependency | Status | Consequence if ignored | +|---|---|---| +| **Tier-2 chat** (`tier2-user-chat/v1`) | Dyad-gate **BLOCK** (ModelPort v1 = bare string, no tools; `monthlyModelBudgetMicroUsd` display-only; fan-in on free-text marker) | Talk facet for *every member* cannot ship on Tier-2 until that design amends. **Talk v1 uses Tier-1 mubot (#505) only.** | +| **ModelPort v2 (tool-calling)** | Named breaking change in `types.ts`; **not built** | Do not plan owner-chat tool loops against ModelPort v1. | +| **Pot monthly model ledger** | Plan field is display-only; live meter is per-agent/day | Any Talk budget story must name a real debit path or stay out of this epic. | +| **Per-project Docs UI (#507)** | Design only | Know facet starts as memory read/write via existing MCP + a thin viewer; full MDX editor is a later docs slice. | +| **Port-4 instinct memory** | Dormant branch (`cursor/task-b143e73d`); zero live callers on main; migration number collision risk | **Learn→Adapt that injects instincts is blocked.** Name Port-4 + learning-ranker as **slice 0 of the learning epic**, gated separately — do not merge that branch from this worktree. | +| **Learning ranker design** | Under dyad-gate / hold | Adapt-by-instinct is out of scope until that gate PASSes and Port-4 is live. Until then Adapt = board plan changes + situation `next_action` only. | +| **Structured project Outcome / KpiSpec** | Not on `projects` (only free-text `goal`) | Slice 1 must add it; do not pretend `projects.goal` is a measurable north-star. | +| **Automatic earned-autonomy widener** | Not built (`Autonomy` is a static column) | Slice 4 introduces propose-widen from verified wins; never auto-blanket keys. | + +> **Generalizable rule (from Tier-2 dyad-gate):** a "reuse" table entry must have a +> *caller on an enforcement path*. Existence, a type, or a dashboard display is +> not enforcement. Dormant branches are prerequisites with owners and gates, not +> dependencies you can lean on. + +## 4. Design — one home, three facets, one loop + +### 4.1 Owner home composition (UI non-goals for this commit) + +First viewport of `/projects/:id` (or a dedicated `/home` that deep-links a +primary project) becomes **one composition**, not a dashboard of widgets: + +1. **Outcome strip** — north-star statement + measured progress (or explicit + `unmeasured` — never a fake 100%). +2. **Trust strip** — current autonomy level + "needs your yes" count from + pending gated reviews. +3. **Three facets** (same page, same project scope): + - **Talk** — Tier-1 mubot chat for the pot's named PM (kayhermes / tenant + mubot). Tier-2 member chat is a later facet once its gate PASSes. + - **Know** — RBAC-filtered project memory / docs list (same store as + `project_recall`). + - **Watch** — existing situation card + task/flight lists (already live). + +No cards-in-hero. Brand/product remains the pot; the mubot is the teammate +voice inside Talk. Stats clusters and secondary marketing blocks stay out of +the first viewport. + +### 4.2 Outcome model (north-star) + +Replace "goal is a sentence" with **Outcome**: + +``` +Outcome { + statement: string // owner's words ("20 booked calls / month") + metric: KpiSpec | null // how progress is measured; null = unmeasured + owner_principal: string // human owner/admin who set it + set_at: string + version: number // increments only on owner change +} +``` + +`KpiSpec` reuses the loop-container idea: named signal + source id. v1 allows +only sources that already have an enforcement/read path (`task_counter`, +`github_prs`, and future connector-backed sources when wired). **Unknown or +unwired source ⇒ progress renders `unmeasured`**, never a guessed green bar. + +Only `owner` / `admin` principals may create or change Outcome. Mubot / agent +tokens may **propose** a restatement (Talk) but the write path requires the +owner principal — encoded in the contract as `mayMubotRedefineGoal === false`. + +### 4.3 Closed loop mapping + +| Step | Owner-experience meaning | Substrate | +|---|---|---| +| **Goal** | Outcome strip | New structured fields (slice 1) | +| **Sense** | Watch situation + metric read | `loadProjectSituation` + KPI source | +| **Rank** | What moves the number next | BrainPort ranking (rank-not-act); learning bias **only after** Port-4+ranker PASS | +| **Act** | Board tasks / flights through technicians | Existing board + BYOA | +| **Receipt** | Win or miss, by receipt id | `workflow_receipts` / verdicts / evidence | +| **Learn** | Distill miss→guardrail, win→playbook | v1: surface `lessons_capture` + gate-fail receipts; v2: Port-4 distill | +| **Adapt** | Plan bends | v1: new board work + kill dead tasks via lifecycle breaker; v2: instinct-biased rank | +| **Gate** | Owner holds risky/irreversible | Existing gate_owner + autonomyImpliesGate + earned widen | + +### 4.4 Earned autonomy (trust widens with verified wins) + +Trust levels map 1:1 onto existing `Autonomy` (no parallel enum): + +| Trust level | Autonomy | Owner feeling | +|---|---|---| +| L0 observe | `suggest` | mubot only proposes | +| L1 draft | `draft` | mubot drafts; owner starts work | +| L2 gated execute | `execute_with_approval` | mubot runs; gate holds the line | +| L3 execute | `execute` | mubot runs within capability envelope | + +**Widen rule (contract-enforced shape):** + +- Input: ordered list of **receipt ids** claimed as wins, current autonomy, + proposed next autonomy, acting principal. +- Pure gate passes only when: + 1. `proposed` is exactly one step above `current` (no skip-to-blanket), + 2. `actingPrincipal` is an owner/admin human (never the mubot agent id), + 3. `verifiedWinCount >= requiredWinsForStep` (v1 default: 3), + 4. every win id is marked `verification: 'resolved_by_id'` — callers that only + have a free-string "source label" fail closed (`unverified_label`). +- Narrowing (after a miss / gate FAIL storm) is always allowed to owner; mubot + may *recommend* narrow, never self-narrow to escape accountability. + +This deliberately does **not** call Port-4. Verification binding to the real +receipt store is a build-slice concern; the contract locks the fail-closed shape +so a later slice cannot "trust the label." + +### 4.5 Honest progress (no fake-green) + +`decideProgressDisplay`: + +- If `metric === null` or source unwired → `{ kind: 'unmeasured' }` +- If KPI signal compute fails → `{ kind: 'unavailable', reason }` (not 0%, not 100%) +- If signal ok → `{ kind: 'measured', value, target, ratio }` with ratio clamped + to \[0, 1\] for display but raw values retained for audit +- **Forbidden:** rendering `kind: 'measured'` from task-count alone when the + Outcome metric names an external signal; task_counter is allowed only when + the Outcome explicitly selected it. + +### 4.6 Visible learning + +Watch (or a Learning sub-facet) lists lessons with: + +- `receipt_id` (required), +- `polarity: 'win' | 'miss'`, +- `summary` (short, owner-facing), +- `source_schema` (e.g. `mupot.lessons_capture/v1`, gate fail schema). + +Entries without a receipt id are rejected by the contract helper. No +"mubot says it learned X" without a backing receipt. + +## 5. Data model (draft — validate in dyad-gate; no migration in this commit) + +``` +-- additive on projects (shape only; slice 1 owns the migration) +outcome_statement TEXT NOT NULL DEFAULT '' -- may mirror goal initially +outcome_metric_json TEXT NULL -- KpiSpec or null +outcome_owner TEXT NULL +outcome_set_at TEXT NULL +outcome_version INTEGER NOT NULL DEFAULT 0 + +-- earned autonomy audit (new table) +autonomy_widen_events ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + agent_id TEXT NOT NULL, + from_autonomy TEXT NOT NULL, + to_autonomy TEXT NOT NULL, + win_receipt_ids TEXT NOT NULL, -- JSON array of receipt ids + decided_by TEXT NOT NULL, -- owner principal + created_at TEXT NOT NULL +) +``` + +No second memory store. Docs continue to target the #507 unification with +`project_remember` / `project_recall`. + +## 6. Boundaries / non-negotiables + +1. Mubot **cannot** redefine Outcome; owner/admin write only. +2. Mubot **cannot** self-verdict gates or widen its own autonomy. +3. Wins/misses/lessons resolve by **receipt id** against pot stores — never by + caller-supplied free-string provenance labels. +4. Progress never fake-greens: unmeasured/unavailable are first-class. +5. Talk v1 = Tier-1 mubot only; Tier-2 is a prerequisite with its own gate. +6. Learn→Adapt via instincts requires Port-4 + learning-ranker PASS as an + explicit prerequisite epic — not silently assumed. +7. Brain remains rank-not-act; owner-experience must not add proposal verbs. +8. Branch-only builds; no merge/deploy/publish from this epic's slices without + dyad-gate + Hadi-go. +9. Do not merge dormant Port-4 / ranker branches from this worktree to "unblock" + learning — they need their own gates (D1 migrations included). + +## 7. Acceptance criteria (design locked when) + +1. Spec + `owner-experience/v1` contract encode the thesis, three facets, loop + steps, and the six principles from the artifact. +2. Pure module encodes: owner-owns-goal, owner-owns-gate, earned-autonomy + one-step widen with verified receipt ids, honest progress, lesson-requires- + receipt, Talk-v1=Tier-1-only. +3. Focused vitest covers those invariants; `tsc --noEmit` clean. +4. Build slices listed; **none implemented** in this commit (no route / + migration / UI). +5. Unmet dependencies named with status — no dormant surface cited as reuse. + +## 8. Build slices (backlog — dyad-gate each; do not start until this design PASSes) + +1. **Outcome model** — structured north-star on `projects` + progress helper + wired to existing KPI sources only; fail closed to `unmeasured`. +2. **Owner home shell** — compose Outcome + Trust + Talk/Know/Watch facets on + project detail (Watch = existing situation; Talk = Tier-1 embed; Know = + memory list). No new cognition. +3. **Trust strip + gate queue** — surface current autonomy + pending + owner-yes items from situation reviews / approvals. +4. **Earned autonomy** — `autonomy_widen_events` + propose/confirm path using + contract gate; capability grants still go through existing + `grant_agent_capability`. +5. **Visible learning** — lesson list from `lessons_capture` + gate-fail + receipts by id on Watch. +6. **Talk facet Tier-2** — only after Tier-2 design amends and PASSes (ModelPort + v2 or constrained prompt→text path explicitly chosen; real budget ledger; + link-table fan-in). +7. **Know facet full docs** — #507 slices (one policy point → memory + unification → editor). +8. **Learn/Adapt v2 (instinct)** — **prerequisite slice 0:** gate+land Port-4 + (migration renumbered) then learning-ranker; only then wire Adapt bias into + Rank. Until then this slice stays backlog. + +Each slice: Kasra-core + diverse second-eye before merge. Unassigned until +pulled from backlog. + +## 9. Open questions (for dyad-gate) + +1. Is the primary owner home `/projects/:id` or a pot-level `/home` that picks + a default project? Lean: enhance `/projects/:id`, add pot home later. +2. Required win count per autonomy step — default 3; product may tune per plan. +3. Should Outcome live only on root projects (not children)? Lean: root-only in + v1 to match two-level hierarchy. +4. Metric connectors beyond `task_counter` / `github_prs` (GHL booked-calls, + PostHog conversions) — each is its own connector slice; owner-experience + must not invent numbers without a source. + +## 10. Related arc + +- Artifact 2026-07-23: chat · docs · board as one relationship. +- #500 lifecycle / #503 BYOA = labor behind Watch. +- #507 / #522–526 = Know. +- #505 = Talk for the few (Tier-1); Tier-2 = Talk for the many (blocked). +- Port-4 + learning ranker = Learn/Adapt depth (explicit unmet). +- This spec is the **unifying owner surface**; it does not replace those epics — + it sequences them behind one coherent experience. diff --git a/src/owner/owner-experience-contract.ts b/src/owner/owner-experience-contract.ts new file mode 100644 index 00000000..d7f51c68 --- /dev/null +++ b/src/owner/owner-experience-contract.ts @@ -0,0 +1,274 @@ +// mupot — Owner-experience unified surface contract (pure). +// +// Design: docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md +// Machine contract: docs/owner-experience-v1.json +// +// Design/spec commit only — no HTTP route, no D1 migration, no UI. +// Locks: owner owns goal+gate, honest receipts-by-id, earned autonomy, +// no fake-green progress, Talk v1 = Tier-1 only, instinct Adapt blocked +// until Port-4 is an explicit prerequisite. + +import type { Autonomy } from '../types' +import { isAutonomy } from '../types' + +/** Facets of the single owner relationship (not three apps). */ +export const OWNER_EXPERIENCE_FACETS = ['talk', 'know', 'watch'] as const + +export type OwnerExperienceFacet = (typeof OWNER_EXPERIENCE_FACETS)[number] + +/** Closed loop the mubot drives; owner holds goal + gate. */ +export const OWNER_EXPERIENCE_LOOP = [ + 'goal', + 'sense', + 'rank', + 'act', + 'receipt', + 'learn', + 'adapt', + 'gate', +] as const + +export type OwnerExperienceLoopStep = (typeof OWNER_EXPERIENCE_LOOP)[number] + +export const OWNER_EXPERIENCE_PRINCIPLES = [ + 'one-face', + 'goals-not-tasks', + 'honest-by-construction', + 'earned-autonomy', + 'visible-learning', + 'owner-owns-goals-and-gates', +] as const + +/** Autonomy ladder used as earned-trust levels (no parallel enum). */ +export const OWNER_TRUST_LADDER: readonly Autonomy[] = [ + 'suggest', + 'draft', + 'execute_with_approval', + 'execute', +] + +export const DEFAULT_REQUIRED_WINS_PER_WIDEN = 3 + +export type TalkRuntimeKind = 'tier1_persistent_mubot' | 'tier2_stateless_user_chat' + +export type WinVerification = 'resolved_by_id' | 'unverified_label' + +export interface VerifiedWinRef { + receiptId: string + verification: WinVerification +} + +export interface EarnedAutonomyWidenInput { + current: Autonomy + proposed: Autonomy + actingPrincipalKind: 'owner_or_admin_human' | 'mubot_or_agent' | 'other' + wins: readonly VerifiedWinRef[] + requiredWins: number +} + +export type EarnedAutonomyWidenDecision = + | { ok: true; from: Autonomy; to: Autonomy; verifiedWinCount: number } + | { + ok: false + reason: + | 'mubot_cannot_self_widen' + | 'invalid_autonomy' + | 'skip_not_allowed' + | 'not_a_widen' + | 'insufficient_verified_wins' + | 'unverified_win_label' + | 'required_wins_invalid' + } + +export type ProgressDisplay = + | { kind: 'measured'; value: number; target: number; ratio: number } + | { kind: 'unmeasured' } + | { kind: 'unavailable'; reason: string } + +export interface OutcomeMetricSpec { + sourceId: string + target: number +} + +export interface LessonDraft { + receiptId: string + polarity: 'win' | 'miss' + summary: string + sourceSchema: string +} + +export type LessonValidation = + | { ok: true; lesson: LessonDraft } + | { + ok: false + reason: 'receipt_id_required' | 'summary_required' | 'source_schema_required' | 'polarity_invalid' + } + +export type InstinctAdaptEligibility = + | { ok: true } + | { + ok: false + reason: 'port4_not_live' | 'learning_ranker_not_passed' | 'prerequisite_missing' + } + +/** Mubot may propose wording; it may never write the Outcome. */ +export function mayMubotRedefineGoal(): boolean { + return false +} + +/** Risky / irreversible acts wait for the owner — mubot never self-verdicts. */ +export function mayMubotSelfVerdictGate(): boolean { + return false +} + +/** Talk facet v1 is Tier-1 only while Tier-2 remains dyad-gate blocked. */ +export function isTalkV1Runtime(kind: TalkRuntimeKind): boolean { + return kind === 'tier1_persistent_mubot' +} + +export function assertTalkV1Runtime(kind: TalkRuntimeKind): void { + if (!isTalkV1Runtime(kind)) { + throw new Error('owner_experience_talk_v1_tier1_only') + } +} + +export function trustLevelForAutonomy(autonomy: Autonomy): number { + const idx = OWNER_TRUST_LADDER.indexOf(autonomy) + if (idx < 0) throw new Error('owner_experience_unknown_autonomy') + return idx +} + +export function autonomyForTrustLevel(level: number): Autonomy { + if (!Number.isInteger(level) || level < 0 || level >= OWNER_TRUST_LADDER.length) { + throw new Error('owner_experience_trust_level_out_of_range') + } + return OWNER_TRUST_LADDER[level] as Autonomy +} + +/** + * One-step earned widen. Wins must be resolved_by_id (not free-string labels). + * Decider must be an owner/admin human — never the mubot. + */ +export function decideEarnedAutonomyWiden( + input: EarnedAutonomyWidenInput, +): EarnedAutonomyWidenDecision { + if (input.actingPrincipalKind !== 'owner_or_admin_human') { + return { ok: false, reason: 'mubot_cannot_self_widen' } + } + if (!isAutonomy(input.current) || !isAutonomy(input.proposed)) { + return { ok: false, reason: 'invalid_autonomy' } + } + if (!Number.isInteger(input.requiredWins) || input.requiredWins < 1) { + return { ok: false, reason: 'required_wins_invalid' } + } + + const fromLevel = trustLevelForAutonomy(input.current) + const toLevel = trustLevelForAutonomy(input.proposed) + if (toLevel <= fromLevel) return { ok: false, reason: 'not_a_widen' } + if (toLevel !== fromLevel + 1) return { ok: false, reason: 'skip_not_allowed' } + + for (const win of input.wins) { + if (win.verification !== 'resolved_by_id') { + return { ok: false, reason: 'unverified_win_label' } + } + if (win.receiptId.trim().length === 0) { + return { ok: false, reason: 'unverified_win_label' } + } + } + + const verifiedWinCount = input.wins.filter( + (win) => win.verification === 'resolved_by_id' && win.receiptId.trim().length > 0, + ).length + if (verifiedWinCount < input.requiredWins) { + return { ok: false, reason: 'insufficient_verified_wins' } + } + + return { + ok: true, + from: input.current, + to: input.proposed, + verifiedWinCount, + } +} + +/** + * Honest progress display. Unwired / missing metric ⇒ unmeasured. + * Never invents a green measured bar from absent data. + */ +export function decideProgressDisplay(input: { + metric: OutcomeMetricSpec | null + sourceWired: boolean + signal: { ok: true; value: number } | { ok: false; reason: string } | null +}): ProgressDisplay { + if (input.metric === null || !input.sourceWired) return { kind: 'unmeasured' } + if (input.signal === null) { + return { kind: 'unavailable', reason: 'signal_missing' } + } + if (!input.signal.ok) { + return { kind: 'unavailable', reason: input.signal.reason } + } + const target = input.metric.target + if (!(target > 0)) { + return { kind: 'unavailable', reason: 'invalid_target' } + } + const value = input.signal.value + const ratio = Math.min(1, Math.max(0, value / target)) + return { kind: 'measured', value, target, ratio } +} + +/** Lessons are visible learning — receipt id is mandatory. */ +export function validateLessonDraft(draft: { + receiptId: string + polarity: string + summary: string + sourceSchema: string +}): LessonValidation { + const receiptId = draft.receiptId.trim() + if (!receiptId) return { ok: false, reason: 'receipt_id_required' } + const summary = draft.summary.trim() + if (!summary) return { ok: false, reason: 'summary_required' } + const sourceSchema = draft.sourceSchema.trim() + if (!sourceSchema) return { ok: false, reason: 'source_schema_required' } + if (draft.polarity !== 'win' && draft.polarity !== 'miss') { + return { ok: false, reason: 'polarity_invalid' } + } + return { + ok: true, + lesson: { + receiptId, + polarity: draft.polarity, + summary, + sourceSchema, + }, + } +} + +/** + * Instinct-biased Adapt is illegal until Port-4 is live AND the learning + * ranker has passed its dyad-gate. Naming the prerequisite beats citing a + * dormant branch as reuse. + */ +export function mayEnableInstinctAdapt(prereq: { + port4LiveOnMain: boolean + learningRankerDyadPassed: boolean +}): InstinctAdaptEligibility { + if (!prereq.port4LiveOnMain) return { ok: false, reason: 'port4_not_live' } + if (!prereq.learningRankerDyadPassed) { + return { ok: false, reason: 'learning_ranker_not_passed' } + } + return { ok: true } +} + +/** Owner home must expose all three facets for a coherent experience. */ +export function assertOwnerHomeFacets(facets: readonly string[]): void { + for (const required of OWNER_EXPERIENCE_FACETS) { + if (!facets.includes(required)) { + throw new Error(`owner_experience_facet_missing: ${required}`) + } + } +} + +/** Rank step must not gain new act verbs via this epic. */ +export function brainRemainsRankNotAct(): boolean { + return true +} diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts new file mode 100644 index 00000000..8a76890c --- /dev/null +++ b/tests/owner-experience-contract.test.ts @@ -0,0 +1,248 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + DEFAULT_REQUIRED_WINS_PER_WIDEN, + OWNER_EXPERIENCE_FACETS, + OWNER_EXPERIENCE_LOOP, + OWNER_EXPERIENCE_PRINCIPLES, + OWNER_TRUST_LADDER, + assertOwnerHomeFacets, + assertTalkV1Runtime, + autonomyForTrustLevel, + brainRemainsRankNotAct, + decideEarnedAutonomyWiden, + decideProgressDisplay, + isTalkV1Runtime, + mayEnableInstinctAdapt, + mayMubotRedefineGoal, + mayMubotSelfVerdictGate, + trustLevelForAutonomy, + validateLessonDraft, +} from '../src/owner/owner-experience-contract' + +const contract = JSON.parse( + readFileSync(new URL('../docs/owner-experience-v1.json', import.meta.url), 'utf8'), +) as { + id: string + status: string + facets: string[] + loop: string[] + principles: string[] + invariants: string[] + talk: { v1Runtime: string; tier2Status: string } + trustLevels: Array<{ level: number; autonomy: string }> + earnedAutonomy: { + maxStepsPerWiden: number + defaultRequiredWins: number + winVerification: string + mubotSelfWiden: boolean + } + progress: { allowedKinds: string[]; forbidFakeGreen: boolean } + unmetDependencies: Array<{ id: string; status: string }> + nonGoals: string[] + buildSlices: string[] +} + +describe('owner-experience/v1 contract doc', () => { + it('is the design-status owner-experience contract', () => { + expect(contract.id).toBe('owner-experience/v1') + expect(contract.status).toBe('design') + expect(contract.talk.v1Runtime).toBe('tier1_persistent_mubot') + expect(contract.talk.tier2Status).toBe('dyad-gate-blocked') + expect(contract.earnedAutonomy.mubotSelfWiden).toBe(false) + expect(contract.earnedAutonomy.winVerification).toBe('resolved_by_id') + expect(contract.progress.forbidFakeGreen).toBe(true) + expect(contract.nonGoals).toContain('pretend-modelport-v2-exists') + expect(contract.nonGoals).toContain('cite-dormant-surfaces-as-reuse') + expect(contract.nonGoals).toContain('merge-port4-or-ranker-from-this-worktree') + }) + + it('matches the pure module facets, loop, and trust ladder', () => { + expect(contract.facets).toEqual([...OWNER_EXPERIENCE_FACETS]) + expect(contract.loop).toEqual([...OWNER_EXPERIENCE_LOOP]) + expect(contract.principles).toEqual([...OWNER_EXPERIENCE_PRINCIPLES]) + expect(contract.earnedAutonomy.defaultRequiredWins).toBe(DEFAULT_REQUIRED_WINS_PER_WIDEN) + expect(contract.earnedAutonomy.maxStepsPerWiden).toBe(1) + expect(contract.trustLevels.map((row) => row.autonomy)).toEqual([...OWNER_TRUST_LADDER]) + }) + + it('locks the load-bearing invariants and names unmet dependencies', () => { + for (const inv of [ + 'owner-owns-goal-mubot-cannot-redefine', + 'owner-owns-gate-mubot-cannot-self-verdict', + 'receipts-resolved-by-id-not-label', + 'progress-never-fake-green', + 'earned-autonomy-one-step-with-verified-wins', + 'lesson-requires-receipt-id', + 'talk-v1-tier1-mubot-only', + 'learn-adapt-instinct-requires-port4-prerequisite', + 'brain-remains-rank-not-act', + ]) { + expect(contract.invariants).toContain(inv) + } + const unmetIds = contract.unmetDependencies.map((row) => row.id) + expect(unmetIds).toContain('port-4-instinct-memory') + expect(unmetIds).toContain('tier2-user-chat') + expect(unmetIds).toContain('model-port-v2-tool-calling') + expect(contract.buildSlices[0]).toBe('outcome-model') + }) +}) + +describe('owner goal and gate ownership', () => { + it('forbids mubot from redefining the outcome or self-verdicting', () => { + expect(mayMubotRedefineGoal()).toBe(false) + expect(mayMubotSelfVerdictGate()).toBe(false) + }) + + it('allows Talk v1 only for Tier-1 mubot runtime', () => { + expect(isTalkV1Runtime('tier1_persistent_mubot')).toBe(true) + expect(isTalkV1Runtime('tier2_stateless_user_chat')).toBe(false) + expect(() => assertTalkV1Runtime('tier1_persistent_mubot')).not.toThrow() + expect(() => assertTalkV1Runtime('tier2_stateless_user_chat')).toThrow( + /owner_experience_talk_v1_tier1_only/, + ) + }) +}) + +describe('earned autonomy', () => { + it('maps trust levels onto the existing Autonomy ladder', () => { + expect(trustLevelForAutonomy('suggest')).toBe(0) + expect(trustLevelForAutonomy('execute')).toBe(3) + expect(autonomyForTrustLevel(2)).toBe('execute_with_approval') + }) + + it('widens one step only when wins are resolved_by_id and decider is owner', () => { + const wins = [ + { receiptId: 'wr-1', verification: 'resolved_by_id' as const }, + { receiptId: 'wr-2', verification: 'resolved_by_id' as const }, + { receiptId: 'wr-3', verification: 'resolved_by_id' as const }, + ] + expect( + decideEarnedAutonomyWiden({ + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins, + requiredWins: 3, + }), + ).toEqual({ ok: true, from: 'suggest', to: 'draft', verifiedWinCount: 3 }) + }) + + it('fails closed on self-widen, skip, labels, and insufficient wins', () => { + expect( + decideEarnedAutonomyWiden({ + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'mubot_or_agent', + wins: [{ receiptId: 'wr-1', verification: 'resolved_by_id' }], + requiredWins: 1, + }), + ).toEqual({ ok: false, reason: 'mubot_cannot_self_widen' }) + + expect( + decideEarnedAutonomyWiden({ + current: 'suggest', + proposed: 'execute', + actingPrincipalKind: 'owner_or_admin_human', + wins: [ + { receiptId: 'a', verification: 'resolved_by_id' }, + { receiptId: 'b', verification: 'resolved_by_id' }, + { receiptId: 'c', verification: 'resolved_by_id' }, + ], + requiredWins: 3, + }), + ).toEqual({ ok: false, reason: 'skip_not_allowed' }) + + expect( + decideEarnedAutonomyWiden({ + current: 'draft', + proposed: 'execute_with_approval', + actingPrincipalKind: 'owner_or_admin_human', + wins: [{ receiptId: 'wr-1', verification: 'unverified_label' }], + requiredWins: 1, + }), + ).toEqual({ ok: false, reason: 'unverified_win_label' }) + + expect( + decideEarnedAutonomyWiden({ + current: 'draft', + proposed: 'execute_with_approval', + actingPrincipalKind: 'owner_or_admin_human', + wins: [{ receiptId: 'wr-1', verification: 'resolved_by_id' }], + requiredWins: 3, + }), + ).toEqual({ ok: false, reason: 'insufficient_verified_wins' }) + }) +}) + +describe('honest progress and visible learning', () => { + it('never fake-greens when metric is missing or source unwired', () => { + expect( + decideProgressDisplay({ metric: null, sourceWired: true, signal: { ok: true, value: 20 } }), + ).toEqual({ kind: 'unmeasured' }) + expect( + decideProgressDisplay({ + metric: { sourceId: 'ghl_booked_calls', target: 20 }, + sourceWired: false, + signal: { ok: true, value: 20 }, + }), + ).toEqual({ kind: 'unmeasured' }) + }) + + it('returns measured only from a wired signal and unavailable on failure', () => { + expect( + decideProgressDisplay({ + metric: { sourceId: 'github_prs', target: 10 }, + sourceWired: true, + signal: { ok: true, value: 5 }, + }), + ).toEqual({ kind: 'measured', value: 5, target: 10, ratio: 0.5 }) + expect( + decideProgressDisplay({ + metric: { sourceId: 'github_prs', target: 10 }, + sourceWired: true, + signal: { ok: false, reason: 'source_error' }, + }), + ).toEqual({ kind: 'unavailable', reason: 'source_error' }) + }) + + it('requires receipt id on lessons', () => { + expect( + validateLessonDraft({ + receiptId: '', + polarity: 'miss', + summary: 'Long form killed sign-ups', + sourceSchema: 'mupot.lessons_capture/v1', + }), + ).toEqual({ ok: false, reason: 'receipt_id_required' }) + + const good = validateLessonDraft({ + receiptId: 'rcpt-9', + polarity: 'miss', + summary: 'Long form killed sign-ups', + sourceSchema: 'mupot.lessons_capture/v1', + }) + expect(good.ok).toBe(true) + if (good.ok) expect(good.lesson.receiptId).toBe('rcpt-9') + }) +}) + +describe('prerequisites and home composition', () => { + it('blocks instinct Adapt until Port-4 is live and ranker has passed', () => { + expect( + mayEnableInstinctAdapt({ port4LiveOnMain: false, learningRankerDyadPassed: false }), + ).toEqual({ ok: false, reason: 'port4_not_live' }) + expect( + mayEnableInstinctAdapt({ port4LiveOnMain: true, learningRankerDyadPassed: false }), + ).toEqual({ ok: false, reason: 'learning_ranker_not_passed' }) + expect( + mayEnableInstinctAdapt({ port4LiveOnMain: true, learningRankerDyadPassed: true }), + ).toEqual({ ok: true }) + }) + + it('requires all three facets on the owner home', () => { + expect(() => assertOwnerHomeFacets(['talk', 'know', 'watch'])).not.toThrow() + expect(() => assertOwnerHomeFacets(['talk', 'watch'])).toThrow(/owner_experience_facet_missing/) + expect(brainRemainsRankNotAct()).toBe(true) + }) +}) From a333f52e69b3737fa991c9878fc5265c4dc614fd Mon Sep 17 00:00:00 2001 From: kasra Date: Mon, 27 Jul 2026 15:43:47 +0000 Subject: [PATCH 2/8] fix(owner): VerifiedWinRef from resolver + scope + consumption Same label-pattern fix as ranker. Wins carry polarity/project/agent; projectId+agentId required on widen input; consumeReceiptIds blocks ladder reuse. mayMubot* take callerKind. Hold push until 5ae1308 lenses land. Co-authored-by: Cursor --- docs/owner-experience-v1.json | 14 +- ...owner-experience-unified-surface-design.md | 29 ++- src/owner/owner-experience-contract.ts | 206 ++++++++++++++++-- tests/owner-experience-contract.test.ts | 159 +++++++++++--- 4 files changed, 350 insertions(+), 58 deletions(-) diff --git a/docs/owner-experience-v1.json b/docs/owner-experience-v1.json index 0fa59772..2698a89b 100644 --- a/docs/owner-experience-v1.json +++ b/docs/owner-experience-v1.json @@ -29,10 +29,13 @@ "receipts-resolved-by-id-not-label", "progress-never-fake-green", "earned-autonomy-one-step-with-verified-wins", + "earned-autonomy-wins-consumed-after-widen", + "earned-autonomy-scope-bound-to-project-and-agent", "lesson-requires-receipt-id", "talk-v1-tier1-mubot-only", "learn-adapt-instinct-requires-port4-prerequisite", - "brain-remains-rank-not-act" + "brain-remains-rank-not-act", + "mechanism-lock-ne-trust-lock" ], "talk": { "v1Runtime": "tier1_persistent_mubot", @@ -48,9 +51,14 @@ "earnedAutonomy": { "maxStepsPerWiden": 1, "defaultRequiredWins": 3, - "winVerification": "resolved_by_id", + "winVerification": "branded-VerifiedWinRef-from-WinReceiptResolver", + "winConstructor": "verifiedWinRefFromResolver", + "trustBoundary": "injected-WinReceiptResolver-like-Port4-InstinctChat", + "winConsumptionPolicy": "mark_consumed_after_successful_widen", + "scopeKeysRequired": ["projectId", "agentId"], "decider": "owner_or_admin_human", - "mubotSelfWiden": false + "mubotSelfWiden": false, + "note": "Same three receipts cannot climb the ladder — consumeReceiptIds must be persisted; top of ladder (execute) is NOT auto-gated per autonomyImpliesGate" }, "progress": { "allowedKinds": ["measured", "unmeasured", "unavailable"], diff --git a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md index 2eb200cb..91b22640 100644 --- a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md +++ b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md @@ -187,21 +187,34 @@ Trust levels map 1:1 onto existing `Autonomy` (no parallel enum): **Widen rule (contract-enforced shape):** -- Input: ordered list of **receipt ids** claimed as wins, current autonomy, - proposed next autonomy, acting principal. +- Input requires **projectId** and **agentId** (scope keys on the gate itself — + not a sibling table convention). Fail closed on mismatch. +- Wins are **branded `VerifiedWinRef`** minted only by + `verifiedWinRefFromResolver(resolver, receiptId, scope)`. The resolver is the + trust boundary (Port-4 InstinctChat pattern). A caller-written + `{ verification: 'resolved_by_id' }` label is rejected — mechanism-lock ≠ + trust-lock. The brand carries resolved **content** (polarity must be `win`, + projectId, agentId, resolvedAt), so constructing it without a real lookup is + structurally impossible at the type boundary. - Pure gate passes only when: 1. `proposed` is exactly one step above `current` (no skip-to-blanket), 2. `actingPrincipal` is an owner/admin human (never the mubot agent id), 3. `verifiedWinCount >= requiredWinsForStep` (v1 default: 3), - 4. every win id is marked `verification: 'resolved_by_id'` — callers that only - have a free-string "source label" fail closed (`unverified_label`). + 4. every win is a branded ref matching the input scope, + 5. none of the win receipt ids appear in `consumedReceiptIds`. +- On success the decision returns `consumeReceiptIds`; the caller must persist + them before the next widen. **Rate/consumption story (named):** + `mark_consumed_after_successful_widen` — the same three receipts cannot walk + suggest → draft → execute_with_approval → execute. Outer defense remains the + audit trail plus mandatory owner review of each widen. +- Worth knowing: per existing `autonomyImpliesGate`, `execute` is NOT + auto-gated (only `execute_with_approval` is). The top of this ladder is the + state where tasks stop being auto-gated. - Narrowing (after a miss / gate FAIL storm) is always allowed to owner; mubot may *recommend* narrow, never self-narrow to escape accountability. -This deliberately does **not** call Port-4. Verification binding to the real -receipt store is a build-slice concern; the contract locks the fail-closed shape -so a later slice cannot "trust the label." - +This deliberately does **not** call Port-4 for instincts. Verification binds to +`workflow_receipts` via the injected resolver at the build-slice wiring layer. ### 4.5 Honest progress (no fake-green) `decideProgressDisplay`: diff --git a/src/owner/owner-experience-contract.ts b/src/owner/owner-experience-contract.ts index d7f51c68..0cb060d6 100644 --- a/src/owner/owner-experience-contract.ts +++ b/src/owner/owner-experience-contract.ts @@ -49,25 +49,132 @@ export const OWNER_TRUST_LADDER: readonly Autonomy[] = [ export const DEFAULT_REQUIRED_WINS_PER_WIDEN = 3 +/** + * Rate/consumption story (named, not implied): + * Successful widen returns consumeReceiptIds; those ids must be passed back in + * consumedReceiptIds on later calls so the same three wins cannot walk the + * full ladder. Audit trail + mandatory owner review remain the outer defense; + * consumption is the in-function fence against reuse. + */ +export const WIN_CONSUMPTION_POLICY = + 'mark_consumed_after_successful_widen' as const + export type TalkRuntimeKind = 'tier1_persistent_mubot' | 'tier2_stateless_user_chat' -export type WinVerification = 'resolved_by_id' | 'unverified_label' +export type PermissionCallerKind = 'owner_or_admin_human' | 'mubot_or_agent' | 'other' -export interface VerifiedWinRef { +/** Record a real workflow_receipts resolver must return. Trust claim ≠ trust object. */ +export interface WinReceiptRecord { receiptId: string - verification: WinVerification + projectId: string + agentId: string + polarity: 'win' | 'miss' + resolvedAt: string +} + +/** + * Injected trust boundary (Port-4 InstinctChat pattern). Production wires + * workflow_receipts; tests inject fakes. Callers never pass a self-describing tag. + */ +export type WinReceiptResolver = (receiptId: string) => WinReceiptRecord | null + +/** + * Opaque trust object — carries resolved content (polarity, scope, time). + * Only `verifiedWinRefFromResolver` may construct it. + */ +export type VerifiedWinRef = { + readonly _brand: 'VerifiedWinRef' + readonly receiptId: string + readonly projectId: string + readonly agentId: string + readonly polarity: 'win' + readonly resolvedAt: string +} + +export type WinRefResolution = + | VerifiedWinRef + | { + ok: false + reason: + | 'unverified_win_label' + | 'scope_mismatch' + | 'not_a_win' + | 'missing_receipt_id' + | 'project_id_required' + | 'agent_id_required' + } + +/** + * Sole constructor for VerifiedWinRef. A plain + * `{ verification: 'resolved_by_id' }` bag is not accepted — that was the + * label pattern (mechanism-lock ≠ trust-lock). + */ +export function verifiedWinRefFromResolver( + resolver: WinReceiptResolver, + receiptId: string, + scope: { projectId: string; agentId: string }, +): WinRefResolution { + const projectId = String(scope.projectId || '').trim() + const agentId = String(scope.agentId || '').trim() + if (!projectId) return { ok: false, reason: 'project_id_required' } + if (!agentId) return { ok: false, reason: 'agent_id_required' } + const id = String(receiptId || '').trim() + if (!id) return { ok: false, reason: 'missing_receipt_id' } + + const record = resolver(id) + if (!record) return { ok: false, reason: 'unverified_win_label' } + if (String(record.receiptId).trim() !== id) { + return { ok: false, reason: 'unverified_win_label' } + } + if ( + String(record.projectId).trim() !== projectId || + String(record.agentId).trim() !== agentId + ) { + return { ok: false, reason: 'scope_mismatch' } + } + if (record.polarity !== 'win') return { ok: false, reason: 'not_a_win' } + const resolvedAt = String(record.resolvedAt || '').trim() + if (!resolvedAt) return { ok: false, reason: 'unverified_win_label' } + + return { + _brand: 'VerifiedWinRef', + receiptId: id, + projectId, + agentId, + polarity: 'win', + resolvedAt, + } +} + +/** @deprecated Label-only shape — rejected by decideEarnedAutonomyWiden. */ +export interface LabeledWinRef { + receiptId: string + verification: 'resolved_by_id' | 'unverified_label' } export interface EarnedAutonomyWidenInput { + /** Project whose binding is being widened — required scope key. */ + projectId: string + /** Agent whose autonomy is being widened — required scope key. */ + agentId: string current: Autonomy proposed: Autonomy - actingPrincipalKind: 'owner_or_admin_human' | 'mubot_or_agent' | 'other' - wins: readonly VerifiedWinRef[] + actingPrincipalKind: PermissionCallerKind + wins: readonly (VerifiedWinRef | LabeledWinRef)[] requiredWins: number + /** Receipt ids already consumed by prior successful widens. */ + consumedReceiptIds: readonly string[] } export type EarnedAutonomyWidenDecision = - | { ok: true; from: Autonomy; to: Autonomy; verifiedWinCount: number } + | { + ok: true + from: Autonomy + to: Autonomy + verifiedWinCount: number + /** Caller must persist these as consumed before the next widen. */ + consumeReceiptIds: readonly string[] + } | { ok: false reason: @@ -78,6 +185,11 @@ export type EarnedAutonomyWidenDecision = | 'insufficient_verified_wins' | 'unverified_win_label' | 'required_wins_invalid' + | 'project_id_required' + | 'agent_id_required' + | 'scope_mismatch' + | 'not_a_win' + | 'win_already_consumed' } export type ProgressDisplay = @@ -111,13 +223,26 @@ export type InstinctAdaptEligibility = reason: 'port4_not_live' | 'learning_ranker_not_passed' | 'prerequisite_missing' } -/** Mubot may propose wording; it may never write the Outcome. */ -export function mayMubotRedefineGoal(): boolean { +/** + * Mubot may propose wording; it may never write the Outcome. + * CallerKind is required so a later slice has a real branch point (not a + * zero-arg constant posing as a permission check). + */ +export function mayMubotRedefineGoal(callerKind: PermissionCallerKind): boolean { + if (callerKind !== 'owner_or_admin_human' && callerKind !== 'mubot_or_agent' && callerKind !== 'other') { + throw new Error('owner_experience_unknown_caller_kind') + } return false } -/** Risky / irreversible acts wait for the owner — mubot never self-verdicts. */ -export function mayMubotSelfVerdictGate(): boolean { +/** + * Risky / irreversible acts wait for the owner — mubot never self-verdicts. + * CallerKind required for the same reason as mayMubotRedefineGoal. + */ +export function mayMubotSelfVerdictGate(callerKind: PermissionCallerKind): boolean { + if (callerKind !== 'owner_or_admin_human' && callerKind !== 'mubot_or_agent' && callerKind !== 'other') { + throw new Error('owner_experience_unknown_caller_kind') + } return false } @@ -145,13 +270,29 @@ export function autonomyForTrustLevel(level: number): Autonomy { return OWNER_TRUST_LADDER[level] as Autonomy } +function isBrandedWinRef(win: VerifiedWinRef | LabeledWinRef): win is VerifiedWinRef { + return ( + typeof win === 'object' && + win !== null && + '_brand' in win && + (win as VerifiedWinRef)._brand === 'VerifiedWinRef' + ) +} + /** - * One-step earned widen. Wins must be resolved_by_id (not free-string labels). - * Decider must be an owner/admin human — never the mubot. + * One-step earned widen. Wins must be branded VerifiedWinRef from resolver + * (not free-string labels). Scope keys on the input are required and checked + * against each win. Decider must be an owner/admin human — never the mubot. + * Consumed receipt ids cannot be reused to climb further steps. */ export function decideEarnedAutonomyWiden( input: EarnedAutonomyWidenInput, ): EarnedAutonomyWidenDecision { + const projectId = String(input.projectId || '').trim() + const agentId = String(input.agentId || '').trim() + if (!projectId) return { ok: false, reason: 'project_id_required' } + if (!agentId) return { ok: false, reason: 'agent_id_required' } + if (input.actingPrincipalKind !== 'owner_or_admin_human') { return { ok: false, reason: 'mubot_cannot_self_widen' } } @@ -167,38 +308,56 @@ export function decideEarnedAutonomyWiden( if (toLevel <= fromLevel) return { ok: false, reason: 'not_a_widen' } if (toLevel !== fromLevel + 1) return { ok: false, reason: 'skip_not_allowed' } + const consumed = new Set( + (input.consumedReceiptIds || []).map((id) => String(id).trim()).filter(Boolean), + ) + const accepted: VerifiedWinRef[] = [] + for (const win of input.wins) { - if (win.verification !== 'resolved_by_id') { + if (!isBrandedWinRef(win)) { return { ok: false, reason: 'unverified_win_label' } } - if (win.receiptId.trim().length === 0) { + if (!win.receiptId.trim()) { return { ok: false, reason: 'unverified_win_label' } } + if (win.projectId !== projectId || win.agentId !== agentId) { + return { ok: false, reason: 'scope_mismatch' } + } + if (win.polarity !== 'win') { + return { ok: false, reason: 'not_a_win' } + } + if (consumed.has(win.receiptId)) { + return { ok: false, reason: 'win_already_consumed' } + } + accepted.push(win) } - const verifiedWinCount = input.wins.filter( - (win) => win.verification === 'resolved_by_id' && win.receiptId.trim().length > 0, - ).length - if (verifiedWinCount < input.requiredWins) { + if (accepted.length < input.requiredWins) { return { ok: false, reason: 'insufficient_verified_wins' } } + const consumeReceiptIds = accepted.slice(0, input.requiredWins).map((w) => w.receiptId) return { ok: true, from: input.current, to: input.proposed, - verifiedWinCount, + verifiedWinCount: accepted.length, + consumeReceiptIds, } } /** * Honest progress display. Unwired / missing metric ⇒ unmeasured. * Never invents a green measured bar from absent data. + * Optional: signal.sourceId must match metric.sourceId when both present. */ export function decideProgressDisplay(input: { metric: OutcomeMetricSpec | null sourceWired: boolean - signal: { ok: true; value: number } | { ok: false; reason: string } | null + signal: + | { ok: true; value: number; sourceId?: string } + | { ok: false; reason: string } + | null }): ProgressDisplay { if (input.metric === null || !input.sourceWired) return { kind: 'unmeasured' } if (input.signal === null) { @@ -207,6 +366,13 @@ export function decideProgressDisplay(input: { if (!input.signal.ok) { return { kind: 'unavailable', reason: input.signal.reason } } + if ( + typeof input.signal.sourceId === 'string' && + input.signal.sourceId.trim().length > 0 && + input.signal.sourceId !== input.metric.sourceId + ) { + return { kind: 'unavailable', reason: 'source_id_mismatch' } + } const target = input.metric.target if (!(target > 0)) { return { kind: 'unavailable', reason: 'invalid_target' } diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts index 8a76890c..5159542f 100644 --- a/tests/owner-experience-contract.test.ts +++ b/tests/owner-experience-contract.test.ts @@ -6,6 +6,7 @@ import { OWNER_EXPERIENCE_LOOP, OWNER_EXPERIENCE_PRINCIPLES, OWNER_TRUST_LADDER, + WIN_CONSUMPTION_POLICY, assertOwnerHomeFacets, assertTalkV1Runtime, autonomyForTrustLevel, @@ -18,6 +19,8 @@ import { mayMubotSelfVerdictGate, trustLevelForAutonomy, validateLessonDraft, + verifiedWinRefFromResolver, + type WinReceiptResolver, } from '../src/owner/owner-experience-contract' const contract = JSON.parse( @@ -36,6 +39,7 @@ const contract = JSON.parse( defaultRequiredWins: number winVerification: string mubotSelfWiden: boolean + winConsumptionPolicy: string } progress: { allowedKinds: string[]; forbidFakeGreen: boolean } unmetDependencies: Array<{ id: string; status: string }> @@ -43,6 +47,40 @@ const contract = JSON.parse( buildSlices: string[] } +function mintWins( + resolver: WinReceiptResolver, + ids: readonly string[], + scope: { projectId: string; agentId: string }, +) { + return ids.map((id) => { + const ref = verifiedWinRefFromResolver(resolver, id, scope) + if (!('_brand' in ref)) throw new Error(`mint failed: ${JSON.stringify(ref)}`) + return ref + }) +} + +const scope = { projectId: 'proj-a', agentId: 'agent-a' } + +const store: WinReceiptResolver = (id) => { + const known: Record = { + 'wr-1': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-2': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-3': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-miss': { polarity: 'miss', projectId: 'proj-a', agentId: 'agent-a' }, + 'wr-other-proj': { polarity: 'win', projectId: 'sandbox', agentId: 'agent-a' }, + 'wr-other-agent': { polarity: 'win', projectId: 'proj-a', agentId: 'agent-b' }, + } + const row = known[id] + if (!row) return null + return { + receiptId: id, + projectId: row.projectId, + agentId: row.agentId, + polarity: row.polarity, + resolvedAt: '2026-07-27T12:00:00.000Z', + } +} + describe('owner-experience/v1 contract doc', () => { it('is the design-status owner-experience contract', () => { expect(contract.id).toBe('owner-experience/v1') @@ -50,7 +88,10 @@ describe('owner-experience/v1 contract doc', () => { expect(contract.talk.v1Runtime).toBe('tier1_persistent_mubot') expect(contract.talk.tier2Status).toBe('dyad-gate-blocked') expect(contract.earnedAutonomy.mubotSelfWiden).toBe(false) - expect(contract.earnedAutonomy.winVerification).toBe('resolved_by_id') + expect(contract.earnedAutonomy.winVerification).toBe( + 'branded-VerifiedWinRef-from-WinReceiptResolver', + ) + expect(contract.earnedAutonomy.winConsumptionPolicy).toBe(WIN_CONSUMPTION_POLICY) expect(contract.progress.forbidFakeGreen).toBe(true) expect(contract.nonGoals).toContain('pretend-modelport-v2-exists') expect(contract.nonGoals).toContain('cite-dormant-surfaces-as-reuse') @@ -73,10 +114,13 @@ describe('owner-experience/v1 contract doc', () => { 'receipts-resolved-by-id-not-label', 'progress-never-fake-green', 'earned-autonomy-one-step-with-verified-wins', + 'earned-autonomy-wins-consumed-after-widen', + 'earned-autonomy-scope-bound-to-project-and-agent', 'lesson-requires-receipt-id', 'talk-v1-tier1-mubot-only', 'learn-adapt-instinct-requires-port4-prerequisite', 'brain-remains-rank-not-act', + 'mechanism-lock-ne-trust-lock', ]) { expect(contract.invariants).toContain(inv) } @@ -89,9 +133,11 @@ describe('owner-experience/v1 contract doc', () => { }) describe('owner goal and gate ownership', () => { - it('forbids mubot from redefining the outcome or self-verdicting', () => { - expect(mayMubotRedefineGoal()).toBe(false) - expect(mayMubotSelfVerdictGate()).toBe(false) + it('forbids mubot from redefining the outcome or self-verdicting (parametrized)', () => { + expect(mayMubotRedefineGoal('mubot_or_agent')).toBe(false) + expect(mayMubotRedefineGoal('owner_or_admin_human')).toBe(false) + expect(mayMubotSelfVerdictGate('mubot_or_agent')).toBe(false) + expect(mayMubotSelfVerdictGate('owner_or_admin_human')).toBe(false) }) it('allows Talk v1 only for Tier-1 mubot runtime', () => { @@ -104,74 +150,126 @@ describe('owner goal and gate ownership', () => { }) }) -describe('earned autonomy', () => { +describe('earned autonomy trust-locks', () => { it('maps trust levels onto the existing Autonomy ladder', () => { expect(trustLevelForAutonomy('suggest')).toBe(0) expect(trustLevelForAutonomy('execute')).toBe(3) expect(autonomyForTrustLevel(2)).toBe('execute_with_approval') }) - it('widens one step only when wins are resolved_by_id and decider is owner', () => { - const wins = [ - { receiptId: 'wr-1', verification: 'resolved_by_id' as const }, - { receiptId: 'wr-2', verification: 'resolved_by_id' as const }, - { receiptId: 'wr-3', verification: 'resolved_by_id' as const }, - ] + it('refuses caller-written resolved_by_id labels (original failing scenario)', () => { + expect( + decideEarnedAutonomyWiden({ + projectId: 'proj-a', + agentId: 'agent-a', + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins: [ + { receiptId: 'wr-1', verification: 'resolved_by_id' }, + { receiptId: 'wr-2', verification: 'resolved_by_id' }, + { receiptId: 'wr-3', verification: 'resolved_by_id' }, + ], + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'unverified_win_label' }) + }) + + it('widens one step only from resolver-minted wins matching scope', () => { + const wins = mintWins(store, ['wr-1', 'wr-2', 'wr-3'], scope) expect( decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, current: 'suggest', proposed: 'draft', actingPrincipalKind: 'owner_or_admin_human', wins, requiredWins: 3, + consumedReceiptIds: [], }), - ).toEqual({ ok: true, from: 'suggest', to: 'draft', verifiedWinCount: 3 }) + ).toEqual({ + ok: true, + from: 'suggest', + to: 'draft', + verifiedWinCount: 3, + consumeReceiptIds: ['wr-1', 'wr-2', 'wr-3'], + }) }) - it('fails closed on self-widen, skip, labels, and insufficient wins', () => { + it('fails closed on self-widen, skip, miss polarity, scope laundering, and consumption', () => { expect( decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, current: 'suggest', proposed: 'draft', actingPrincipalKind: 'mubot_or_agent', - wins: [{ receiptId: 'wr-1', verification: 'resolved_by_id' }], + wins: mintWins(store, ['wr-1'], scope), requiredWins: 1, + consumedReceiptIds: [], }), ).toEqual({ ok: false, reason: 'mubot_cannot_self_widen' }) expect( decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, current: 'suggest', proposed: 'execute', actingPrincipalKind: 'owner_or_admin_human', - wins: [ - { receiptId: 'a', verification: 'resolved_by_id' }, - { receiptId: 'b', verification: 'resolved_by_id' }, - { receiptId: 'c', verification: 'resolved_by_id' }, - ], + wins: mintWins(store, ['wr-1', 'wr-2', 'wr-3'], scope), requiredWins: 3, + consumedReceiptIds: [], }), ).toEqual({ ok: false, reason: 'skip_not_allowed' }) + expect(verifiedWinRefFromResolver(store, 'wr-miss', scope)).toEqual({ + ok: false, + reason: 'not_a_win', + }) + expect(verifiedWinRefFromResolver(store, 'wr-other-proj', scope)).toEqual({ + ok: false, + reason: 'scope_mismatch', + }) + expect(verifiedWinRefFromResolver(store, 'wr-other-agent', scope)).toEqual({ + ok: false, + reason: 'scope_mismatch', + }) + + // Win laundering: mint against sandbox, attempt widen on customer project. + const sandboxWins = mintWins( + store, + ['wr-other-proj'], + { projectId: 'sandbox', agentId: 'agent-a' }, + ) expect( decideEarnedAutonomyWiden({ - current: 'draft', - proposed: 'execute_with_approval', + projectId: 'proj-a', + agentId: 'agent-a', + current: 'suggest', + proposed: 'draft', actingPrincipalKind: 'owner_or_admin_human', - wins: [{ receiptId: 'wr-1', verification: 'unverified_label' }], + wins: sandboxWins, requiredWins: 1, + consumedReceiptIds: [], }), - ).toEqual({ ok: false, reason: 'unverified_win_label' }) + ).toEqual({ ok: false, reason: 'scope_mismatch' }) + const wins = mintWins(store, ['wr-1', 'wr-2', 'wr-3'], scope) expect( decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, current: 'draft', proposed: 'execute_with_approval', actingPrincipalKind: 'owner_or_admin_human', - wins: [{ receiptId: 'wr-1', verification: 'resolved_by_id' }], + wins, requiredWins: 3, + consumedReceiptIds: ['wr-1', 'wr-2', 'wr-3'], }), - ).toEqual({ ok: false, reason: 'insufficient_verified_wins' }) + ).toEqual({ ok: false, reason: 'win_already_consumed' }) }) }) @@ -189,14 +287,21 @@ describe('honest progress and visible learning', () => { ).toEqual({ kind: 'unmeasured' }) }) - it('returns measured only from a wired signal and unavailable on failure', () => { + it('returns measured only from a wired signal and unavailable on mismatch/failure', () => { expect( decideProgressDisplay({ metric: { sourceId: 'github_prs', target: 10 }, sourceWired: true, - signal: { ok: true, value: 5 }, + signal: { ok: true, value: 5, sourceId: 'github_prs' }, }), ).toEqual({ kind: 'measured', value: 5, target: 10, ratio: 0.5 }) + expect( + decideProgressDisplay({ + metric: { sourceId: 'github_prs', target: 10 }, + sourceWired: true, + signal: { ok: true, value: 5, sourceId: 'other_source' }, + }), + ).toEqual({ kind: 'unavailable', reason: 'source_id_mismatch' }) expect( decideProgressDisplay({ metric: { sourceId: 'github_prs', target: 10 }, From 70127a5ff408296650eff55deffdfb9043020dce Mon Sep 17 00:00:00 2001 From: kasra Date: Mon, 27 Jul 2026 17:01:48 +0000 Subject: [PATCH 3/8] =?UTF-8?q?fix(owner):=20clear=20dyad=20hard=20blocks?= =?UTF-8?q?=20=E2=80=94=20Talk=20optional,=20unmeasured=20v1,=20trust-lock?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision: Talk optional until #505 revived. BrainPort out of reuse (rankTasks interim). Progress: Outcome + KpiSourceId, no sourceWired, v1 unmeasured. Widen: dedup + minRequiredWins=3 + VerifiedWinRef resolver + scope + consume. Co-authored-by: Cursor --- docs/owner-experience-v1.json | 45 ++++++++++- ...owner-experience-unified-surface-design.md | 11 ++- src/owner/owner-experience-contract.ts | 56 +++++++++---- tests/owner-experience-contract.test.ts | 81 +++++++++++++------ 4 files changed, 145 insertions(+), 48 deletions(-) diff --git a/docs/owner-experience-v1.json b/docs/owner-experience-v1.json index 2698a89b..17154bad 100644 --- a/docs/owner-experience-v1.json +++ b/docs/owner-experience-v1.json @@ -5,6 +5,8 @@ "thesis": "Owner owns the goal and the gate; the mubot owns execution and adaptation; one relationship with Talk, Know, and Watch facets bound to a single project outcome.", "artifactRef": "4cb8d84f-b5b9-4a55-a47c-3af193161b19", "facets": ["talk", "know", "watch"], + "homeRequiredFacets": ["know", "watch"], + "talkOptionalUntil": "tier1-persistent-mubot-505-revived", "loop": [ "goal", "sense", @@ -32,15 +34,17 @@ "earned-autonomy-wins-consumed-after-widen", "earned-autonomy-scope-bound-to-project-and-agent", "lesson-requires-receipt-id", - "talk-v1-tier1-mubot-only", + "talk-v1-tier1-mubot-only-when-present", "learn-adapt-instinct-requires-port4-prerequisite", "brain-remains-rank-not-act", "mechanism-lock-ne-trust-lock" ], "talk": { "v1Runtime": "tier1_persistent_mubot", + "tier1Status": "closed-unmerged-pr-505", "tier2Status": "dyad-gate-blocked", - "tier2AllowedOnlyAfter": "tier2-user-chat/v1-pass" + "tier2AllowedOnlyAfter": "tier2-user-chat/v1-pass", + "homePolicy": "optional-facet-until-tier1-revived" }, "trustLevels": [ { "level": 0, "autonomy": "suggest" }, @@ -51,10 +55,12 @@ "earnedAutonomy": { "maxStepsPerWiden": 1, "defaultRequiredWins": 3, + "minRequiredWins": 3, "winVerification": "branded-VerifiedWinRef-from-WinReceiptResolver", "winConstructor": "verifiedWinRefFromResolver", "trustBoundary": "injected-WinReceiptResolver-like-Port4-InstinctChat", "winConsumptionPolicy": "mark_consumed_after_successful_widen", + "dedupByReceiptId": true, "scopeKeysRequired": ["projectId", "agentId"], "decider": "owner_or_admin_human", "mubotSelfWiden": false, @@ -63,14 +69,34 @@ "progress": { "allowedKinds": ["measured", "unmeasured", "unavailable"], "forbidFakeGreen": true, - "unwiredSource": "unmeasured" + "v1Mode": "unmeasured_until_project_kpi", + "metricSourceIdType": "KpiSourceId", + "sourceWiredCallerBoolean": false }, "unmetDependencies": [ + { + "id": "tier1-persistent-mubot-505", + "status": "closed-unmerged", + "blocks": ["talk-facet"], + "decision": "talk-optional-on-owner-home-until-revived" + }, { "id": "tier2-user-chat", "status": "dyad-gate-blocked", "blocks": ["talk-facet-every-member"] }, + { + "id": "brainport-default-adapter", + "status": "type-only-sealed", + "blocks": ["rank-via-BrainPort"], + "interim": "rankTasks" + }, + { + "id": "project-scoped-kpi-source", + "status": "not-built", + "blocks": ["measured-progress"], + "v1": "unmeasured_until_project_kpi" + }, { "id": "model-port-v2-tool-calling", "status": "not-built", @@ -108,7 +134,8 @@ "ui-in-this-design-commit", "merge-port4-or-ranker-from-this-worktree", "pretend-modelport-v2-exists", - "cite-dormant-surfaces-as-reuse" + "cite-dormant-surfaces-as-reuse", + "cite-BrainPort-as-reuse" ], "buildSlices": [ "outcome-model", @@ -119,5 +146,15 @@ "talk-facet-tier2-after-pass", "know-facet-docs-507", "learn-adapt-instinct-after-port4" + ], + "suggestedBuildOrder": [ + "trust-strip-gate-queue", + "visible-learning", + "outcome-model", + "earned-autonomy", + "owner-home-shell", + "know-facet-docs-507", + "talk-facet-tier2-after-pass", + "learn-adapt-instinct-after-port4" ] } diff --git a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md index 91b22640..4ac675d4 100644 --- a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md +++ b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md @@ -93,16 +93,18 @@ These are acceptance *feelings*, not build slices. Every slice must preserve the | `lessons_capture` receipt | `src/projects/completion-gate.ts` | First visible-learning feed (project terminal) | | Agent `Autonomy` enum | `src/types.ts` `suggest\|draft\|execute\|execute_with_approval` | Trust strip vocabulary — **static dial today** | | `autonomyImpliesGate` | `src/org/service.ts` | Gate implication already enforced on task create | -| BrainPort v1 (rank-only) | `src/types.ts` `BrainPort` | Rank step must stay rank-not-act | -| Agent KPI helpers | `src/agents/kpi-sources.ts` (`task_counter`, `github_prs`) | Pattern for outcome measurement — **agent-scoped, not project-owner UX** | -| Project memory MCP | `project_remember` / `project_recall` / `project_context` | Know facet store (same store docs must use — #507) | | `grant_agent_capability` | `src/mcp/provision.ts` | Manual capability path; earned-autonomy proposes, owner confirms | +| Live task ranking | `src/tasks/ranking.ts` `rankTasks` (callers: mcp/index, tasks/index) | Rank facet interim surface until BrainPort default adapter exists | +| Project memory MCP | `project_remember` / `project_recall` / `project_context` | Know facet store (same store docs must use — #507) | ### 3.2 Explicit unmet dependencies (not cited as reuse) | Dependency | Status | Consequence if ignored | |---|---|---| -| **Tier-2 chat** (`tier2-user-chat/v1`) | Dyad-gate **BLOCK** (ModelPort v1 = bare string, no tools; `monthlyModelBudgetMicroUsd` display-only; fan-in on free-text marker) | Talk facet for *every member* cannot ship on Tier-2 until that design amends. **Talk v1 uses Tier-1 mubot (#505) only.** | +| **Tier-1 persistent mubot chat (#505)** | PR **CLOSED unmerged**; no `src/chat`; no successor | Talk facet has **zero** implementation. **Decision: Talk is OPTIONAL on owner home** until #505 is revived or replaced. Slice 2 ships Know+Watch; `assertOwnerHomeFacets` requires only those two. | +| **Tier-2 chat** (`tier2-user-chat/v1`) | Dyad-gate **BLOCK** | Talk for every member cannot ship on Tier-2 until that design amends. | +| **BrainPort default adapter** | Type-only SEALED; zero implementations / zero callers | Do **not** cite BrainPort under reuse. Rank step maps to live `rankTasks` interim, or waits for ranker's `brainport-default-adapter` slice. | +| **Project-scoped KPI source** | Not built (`task_counter`/`github_prs` are agent/tenant scoped) | v1 Outcome progress is **`unmeasured_until_project_kpi`** — never render measured from wrong-scope sources. | | **ModelPort v2 (tool-calling)** | Named breaking change in `types.ts`; **not built** | Do not plan owner-chat tool loops against ModelPort v1. | | **Pot monthly model ledger** | Plan field is display-only; live meter is per-agent/day | Any Talk budget story must name a real debit path or stay out of this epic. | | **Per-project Docs UI (#507)** | Design only | Know facet starts as memory read/write via existing MCP + a thin viewer; full MDX editor is a later docs slice. | @@ -110,6 +112,7 @@ These are acceptance *feelings*, not build slices. Every slice must preserve the | **Learning ranker design** | Under dyad-gate / hold | Adapt-by-instinct is out of scope until that gate PASSes and Port-4 is live. Until then Adapt = board plan changes + situation `next_action` only. | | **Structured project Outcome / KpiSpec** | Not on `projects` (only free-text `goal`) | Slice 1 must add it; do not pretend `projects.goal` is a measurable north-star. | | **Automatic earned-autonomy widener** | Not built (`Autonomy` is a static column) | Slice 4 introduces propose-widen from verified wins; never auto-blanket keys. | +| **Project concierge** | Existing surface | Remains a deep-link into owner home facets — not a fourth panel; no parallel UX. | > **Generalizable rule (from Tier-2 dyad-gate):** a "reuse" table entry must have a > *caller on an enforcement path*. Existence, a type, or a dashboard display is diff --git a/src/owner/owner-experience-contract.ts b/src/owner/owner-experience-contract.ts index 0cb060d6..b21e60fd 100644 --- a/src/owner/owner-experience-contract.ts +++ b/src/owner/owner-experience-contract.ts @@ -48,6 +48,13 @@ export const OWNER_TRUST_LADDER: readonly Autonomy[] = [ ] export const DEFAULT_REQUIRED_WINS_PER_WIDEN = 3 +export const MIN_REQUIRED_WINS_PER_WIDEN = 3 + +export type KpiSourceId = 'task_counter' | 'github_prs' +export const KPI_SOURCE_IDS: readonly KpiSourceId[] = ['task_counter', 'github_prs'] +export function isKpiSourceId(v: unknown): v is KpiSourceId { + return typeof v === 'string' && (KPI_SOURCE_IDS as readonly string[]).includes(v) +} /** * Rate/consumption story (named, not implied): @@ -198,10 +205,18 @@ export type ProgressDisplay = | { kind: 'unavailable'; reason: string } export interface OutcomeMetricSpec { - sourceId: string + sourceId: KpiSourceId target: number } +/** Structured north-star — not free-text projects.goal. */ +export interface Outcome { + statement: string + metric: OutcomeMetricSpec | null + /** v1: existing agent KPI sources cannot measure a project → unmeasured. */ + measurementMode: 'unmeasured_until_project_kpi' | 'measured' +} + export interface LessonDraft { receiptId: string polarity: 'win' | 'miss' @@ -299,7 +314,7 @@ export function decideEarnedAutonomyWiden( if (!isAutonomy(input.current) || !isAutonomy(input.proposed)) { return { ok: false, reason: 'invalid_autonomy' } } - if (!Number.isInteger(input.requiredWins) || input.requiredWins < 1) { + if (!Number.isInteger(input.requiredWins) || input.requiredWins < MIN_REQUIRED_WINS_PER_WIDEN) { return { ok: false, reason: 'required_wins_invalid' } } @@ -312,6 +327,7 @@ export function decideEarnedAutonomyWiden( (input.consumedReceiptIds || []).map((id) => String(id).trim()).filter(Boolean), ) const accepted: VerifiedWinRef[] = [] + const seenIds = new Set() for (const win of input.wins) { if (!isBrandedWinRef(win)) { @@ -320,6 +336,9 @@ export function decideEarnedAutonomyWiden( if (!win.receiptId.trim()) { return { ok: false, reason: 'unverified_win_label' } } + if (seenIds.has(win.receiptId)) { + continue // dedup — one receipt counts once + } if (win.projectId !== projectId || win.agentId !== agentId) { return { ok: false, reason: 'scope_mismatch' } } @@ -329,6 +348,7 @@ export function decideEarnedAutonomyWiden( if (consumed.has(win.receiptId)) { return { ok: false, reason: 'win_already_consumed' } } + seenIds.add(win.receiptId) accepted.push(win) } @@ -347,33 +367,34 @@ export function decideEarnedAutonomyWiden( } /** - * Honest progress display. Unwired / missing metric ⇒ unmeasured. + * Honest progress display. Unwired / missing / non-allowlisted metric ⇒ unmeasured. * Never invents a green measured bar from absent data. - * Optional: signal.sourceId must match metric.sourceId when both present. + * Caller `sourceWired` boolean REMOVED — wired-ness derived from KpiSourceId allowlist. + * v1 `unmeasured_until_project_kpi` always returns unmeasured (existing KPI sources + * are agent-scoped, not project-owner UX — BLOCK-B). */ export function decideProgressDisplay(input: { - metric: OutcomeMetricSpec | null - sourceWired: boolean + outcome: Outcome signal: - | { ok: true; value: number; sourceId?: string } + | { ok: true; value: number; sourceId: KpiSourceId } | { ok: false; reason: string } | null }): ProgressDisplay { - if (input.metric === null || !input.sourceWired) return { kind: 'unmeasured' } + if (input.outcome.measurementMode === 'unmeasured_until_project_kpi') { + return { kind: 'unmeasured' } + } + if (input.outcome.metric === null) return { kind: 'unmeasured' } + if (!isKpiSourceId(input.outcome.metric.sourceId)) return { kind: 'unmeasured' } if (input.signal === null) { return { kind: 'unavailable', reason: 'signal_missing' } } if (!input.signal.ok) { return { kind: 'unavailable', reason: input.signal.reason } } - if ( - typeof input.signal.sourceId === 'string' && - input.signal.sourceId.trim().length > 0 && - input.signal.sourceId !== input.metric.sourceId - ) { + if (input.signal.sourceId !== input.outcome.metric.sourceId) { return { kind: 'unavailable', reason: 'source_id_mismatch' } } - const target = input.metric.target + const target = input.outcome.metric.target if (!(target > 0)) { return { kind: 'unavailable', reason: 'invalid_target' } } @@ -425,9 +446,12 @@ export function mayEnableInstinctAdapt(prereq: { return { ok: true } } -/** Owner home must expose all three facets for a coherent experience. */ +/** Facets Know+Watch are required for v1 home; Talk is OPTIONAL until #505 is revived. */ +export const OWNER_HOME_REQUIRED_FACETS = ['know', 'watch'] as const + +/** Owner home must expose Know+Watch. Talk is optional while Tier-1 #505 is closed-unmerged. */ export function assertOwnerHomeFacets(facets: readonly string[]): void { - for (const required of OWNER_EXPERIENCE_FACETS) { + for (const required of OWNER_HOME_REQUIRED_FACETS) { if (!facets.includes(required)) { throw new Error(`owner_experience_facet_missing: ${required}`) } diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts index 5159542f..fe7815a1 100644 --- a/tests/owner-experience-contract.test.ts +++ b/tests/owner-experience-contract.test.ts @@ -32,7 +32,7 @@ const contract = JSON.parse( loop: string[] principles: string[] invariants: string[] - talk: { v1Runtime: string; tier2Status: string } + talk: { v1Runtime: string; tier2Status: string; homePolicy?: string } trustLevels: Array<{ level: number; autonomy: string }> earnedAutonomy: { maxStepsPerWiden: number @@ -93,9 +93,11 @@ describe('owner-experience/v1 contract doc', () => { ) expect(contract.earnedAutonomy.winConsumptionPolicy).toBe(WIN_CONSUMPTION_POLICY) expect(contract.progress.forbidFakeGreen).toBe(true) + expect(contract.talk.homePolicy).toBe('optional-facet-until-tier1-revived') expect(contract.nonGoals).toContain('pretend-modelport-v2-exists') expect(contract.nonGoals).toContain('cite-dormant-surfaces-as-reuse') expect(contract.nonGoals).toContain('merge-port4-or-ranker-from-this-worktree') + expect(contract.nonGoals).toContain('cite-BrainPort-as-reuse') }) it('matches the pure module facets, loop, and trust ladder', () => { @@ -117,7 +119,7 @@ describe('owner-experience/v1 contract doc', () => { 'earned-autonomy-wins-consumed-after-widen', 'earned-autonomy-scope-bound-to-project-and-agent', 'lesson-requires-receipt-id', - 'talk-v1-tier1-mubot-only', + 'talk-v1-tier1-mubot-only-when-present', 'learn-adapt-instinct-requires-port4-prerequisite', 'brain-remains-rank-not-act', 'mechanism-lock-ne-trust-lock', @@ -125,9 +127,11 @@ describe('owner-experience/v1 contract doc', () => { expect(contract.invariants).toContain(inv) } const unmetIds = contract.unmetDependencies.map((row) => row.id) + expect(unmetIds).toContain('tier1-persistent-mubot-505') expect(unmetIds).toContain('port-4-instinct-memory') expect(unmetIds).toContain('tier2-user-chat') expect(unmetIds).toContain('model-port-v2-tool-calling') + expect(unmetIds).toContain('brainport-default-adapter') expect(contract.buildSlices[0]).toBe('outcome-model') }) }) @@ -252,7 +256,7 @@ describe('earned autonomy trust-locks', () => { proposed: 'draft', actingPrincipalKind: 'owner_or_admin_human', wins: sandboxWins, - requiredWins: 1, + requiredWins: 3, consumedReceiptIds: [], }), ).toEqual({ ok: false, reason: 'scope_mismatch' }) @@ -270,45 +274,73 @@ describe('earned autonomy trust-locks', () => { consumedReceiptIds: ['wr-1', 'wr-2', 'wr-3'], }), ).toEqual({ ok: false, reason: 'win_already_consumed' }) + + // Dedup: same receipt thrice does not count as three wins. + const one = mintWins(store, ['wr-1'], scope)[0] + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins: [one, one, one], + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'insufficient_verified_wins' }) + + // Floor: requiredWins < 3 rejected. + expect( + decideEarnedAutonomyWiden({ + projectId: scope.projectId, + agentId: scope.agentId, + current: 'execute_with_approval', + proposed: 'execute', + actingPrincipalKind: 'owner_or_admin_human', + wins: mintWins(store, ['wr-1'], scope), + requiredWins: 1, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'required_wins_invalid' }) }) }) describe('honest progress and visible learning', () => { - it('never fake-greens when metric is missing or source unwired', () => { - expect( - decideProgressDisplay({ metric: null, sourceWired: true, signal: { ok: true, value: 20 } }), - ).toEqual({ kind: 'unmeasured' }) + it('v1 ships unmeasured — agent KPI sources cannot measure a project', () => { expect( decideProgressDisplay({ - metric: { sourceId: 'ghl_booked_calls', target: 20 }, - sourceWired: false, - signal: { ok: true, value: 20 }, + outcome: { + statement: 'Booked calls', + metric: { sourceId: 'task_counter', target: 20 }, + measurementMode: 'unmeasured_until_project_kpi', + }, + signal: { ok: true, value: 20, sourceId: 'task_counter' }, }), ).toEqual({ kind: 'unmeasured' }) }) - it('returns measured only from a wired signal and unavailable on mismatch/failure', () => { + it('measured path requires allowlisted KpiSourceId and matching signal — no sourceWired boolean', () => { expect( decideProgressDisplay({ - metric: { sourceId: 'github_prs', target: 10 }, - sourceWired: true, + outcome: { + statement: 'PRs', + metric: { sourceId: 'github_prs', target: 10 }, + measurementMode: 'measured', + }, signal: { ok: true, value: 5, sourceId: 'github_prs' }, }), ).toEqual({ kind: 'measured', value: 5, target: 10, ratio: 0.5 }) expect( decideProgressDisplay({ - metric: { sourceId: 'github_prs', target: 10 }, - sourceWired: true, - signal: { ok: true, value: 5, sourceId: 'other_source' }, + outcome: { + statement: 'PRs', + metric: { sourceId: 'github_prs', target: 10 }, + measurementMode: 'measured', + }, + signal: { ok: true, value: 5, sourceId: 'task_counter' }, }), ).toEqual({ kind: 'unavailable', reason: 'source_id_mismatch' }) - expect( - decideProgressDisplay({ - metric: { sourceId: 'github_prs', target: 10 }, - sourceWired: true, - signal: { ok: false, reason: 'source_error' }, - }), - ).toEqual({ kind: 'unavailable', reason: 'source_error' }) }) it('requires receipt id on lessons', () => { @@ -345,7 +377,8 @@ describe('prerequisites and home composition', () => { ).toEqual({ ok: true }) }) - it('requires all three facets on the owner home', () => { + it('requires Know+Watch; Talk optional while #505 is closed-unmerged', () => { + expect(() => assertOwnerHomeFacets(['know', 'watch'])).not.toThrow() expect(() => assertOwnerHomeFacets(['talk', 'know', 'watch'])).not.toThrow() expect(() => assertOwnerHomeFacets(['talk', 'watch'])).toThrow(/owner_experience_facet_missing/) expect(brainRemainsRankNotAct()).toBe(true) From fb76b2f5f63aa0c05fc33c50e145e9f0ce2b7e83 Mon Sep 17 00:00:00 2001 From: kasra Date: Mon, 27 Jul 2026 17:11:51 +0000 Subject: [PATCH 4/8] fix(owner): WeakSet provenance on VerifiedWinRef MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same defect class as ranker VerifiedReceiptRef — cosmetic _brand was forgeable. Mint registers in WeakSet + Object.freeze; gate checks registry. Reproduce-and-refuse: forged brand → unverified_win_label. Co-authored-by: Cursor --- docs/owner-experience-v1.json | 4 +-- ...owner-experience-unified-surface-design.md | 14 +++++----- src/owner/owner-experience-contract.ts | 20 +++++++++----- tests/owner-experience-contract.test.ts | 26 +++++++++++++++++-- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/docs/owner-experience-v1.json b/docs/owner-experience-v1.json index 17154bad..5e4f55fc 100644 --- a/docs/owner-experience-v1.json +++ b/docs/owner-experience-v1.json @@ -56,9 +56,9 @@ "maxStepsPerWiden": 1, "defaultRequiredWins": 3, "minRequiredWins": 3, - "winVerification": "branded-VerifiedWinRef-from-WinReceiptResolver", + "winVerification": "branded-VerifiedWinRef-from-WinReceiptResolver-WeakSet-registry", "winConstructor": "verifiedWinRefFromResolver", - "trustBoundary": "injected-WinReceiptResolver-like-Port4-InstinctChat", + "trustBoundary": "injected-WinReceiptResolver-plus-WeakSet-like-ranker-VerifiedReceiptRef", "winConsumptionPolicy": "mark_consumed_after_successful_widen", "dedupByReceiptId": true, "scopeKeysRequired": ["projectId", "agentId"], diff --git a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md index 4ac675d4..2ba8cf1c 100644 --- a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md +++ b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md @@ -193,12 +193,14 @@ Trust levels map 1:1 onto existing `Autonomy` (no parallel enum): - Input requires **projectId** and **agentId** (scope keys on the gate itself — not a sibling table convention). Fail closed on mismatch. - Wins are **branded `VerifiedWinRef`** minted only by - `verifiedWinRefFromResolver(resolver, receiptId, scope)`. The resolver is the - trust boundary (Port-4 InstinctChat pattern). A caller-written - `{ verification: 'resolved_by_id' }` label is rejected — mechanism-lock ≠ - trust-lock. The brand carries resolved **content** (polarity must be `win`, - projectId, agentId, resolvedAt), so constructing it without a real lookup is - structurally impossible at the type boundary. + `verifiedWinRefFromResolver(resolver, receiptId, scope)`, registered in a + runtime **WeakSet**, and frozen. The resolver is the trust boundary (Port-4 + InstinctChat pattern); the WeakSet is the provenance check (same class as + ranker's `VerifiedReceiptRef`). A caller-written + `{ verification: 'resolved_by_id' }` label **or** a forged `{ _brand: + 'VerifiedWinRef', ... }` literal is rejected — mechanism-lock ≠ trust-lock. + The brand carries resolved **content** (polarity must be `win`, projectId, + agentId, resolvedAt). - Pure gate passes only when: 1. `proposed` is exactly one step above `current` (no skip-to-blanket), 2. `actingPrincipal` is an owner/admin human (never the mubot agent id), diff --git a/src/owner/owner-experience-contract.ts b/src/owner/owner-experience-contract.ts index b21e60fd..634db8b0 100644 --- a/src/owner/owner-experience-contract.ts +++ b/src/owner/owner-experience-contract.ts @@ -87,7 +87,8 @@ export type WinReceiptResolver = (receiptId: string) => WinReceiptRecord | null /** * Opaque trust object — carries resolved content (polarity, scope, time). - * Only `verifiedWinRefFromResolver` may construct it. + * Only `verifiedWinRefFromResolver` may construct it, and only refs in the + * runtime WeakSet registry are accepted by decideEarnedAutonomyWiden. */ export type VerifiedWinRef = { readonly _brand: 'VerifiedWinRef' @@ -98,6 +99,9 @@ export type VerifiedWinRef = { readonly resolvedAt: string } +/** Runtime provenance — shape/_brand alone is not enough (same class as ranker). */ +const verifiedWinRegistry = new WeakSet() + export type WinRefResolution = | VerifiedWinRef | { @@ -114,7 +118,8 @@ export type WinRefResolution = /** * Sole constructor for VerifiedWinRef. A plain * `{ verification: 'resolved_by_id' }` bag is not accepted — that was the - * label pattern (mechanism-lock ≠ trust-lock). + * label pattern (mechanism-lock ≠ trust-lock). Forged `_brand` literals are + * refused at the gate via WeakSet. */ export function verifiedWinRefFromResolver( resolver: WinReceiptResolver, @@ -143,14 +148,16 @@ export function verifiedWinRefFromResolver( const resolvedAt = String(record.resolvedAt || '').trim() if (!resolvedAt) return { ok: false, reason: 'unverified_win_label' } - return { - _brand: 'VerifiedWinRef', + const ref: VerifiedWinRef = Object.freeze({ + _brand: 'VerifiedWinRef' as const, receiptId: id, projectId, agentId, - polarity: 'win', + polarity: 'win' as const, resolvedAt, - } + }) + verifiedWinRegistry.add(ref) + return ref } /** @deprecated Label-only shape — rejected by decideEarnedAutonomyWiden. */ @@ -289,6 +296,7 @@ function isBrandedWinRef(win: VerifiedWinRef | LabeledWinRef): win is VerifiedWi return ( typeof win === 'object' && win !== null && + verifiedWinRegistry.has(win as object) && '_brand' in win && (win as VerifiedWinRef)._brand === 'VerifiedWinRef' ) diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts index fe7815a1..fa53900f 100644 --- a/tests/owner-experience-contract.test.ts +++ b/tests/owner-experience-contract.test.ts @@ -89,7 +89,7 @@ describe('owner-experience/v1 contract doc', () => { expect(contract.talk.tier2Status).toBe('dyad-gate-blocked') expect(contract.earnedAutonomy.mubotSelfWiden).toBe(false) expect(contract.earnedAutonomy.winVerification).toBe( - 'branded-VerifiedWinRef-from-WinReceiptResolver', + 'branded-VerifiedWinRef-from-WinReceiptResolver-WeakSet-registry', ) expect(contract.earnedAutonomy.winConsumptionPolicy).toBe(WIN_CONSUMPTION_POLICY) expect(contract.progress.forbidFakeGreen).toBe(true) @@ -161,7 +161,7 @@ describe('earned autonomy trust-locks', () => { expect(autonomyForTrustLevel(2)).toBe('execute_with_approval') }) - it('refuses caller-written resolved_by_id labels (original failing scenario)', () => { + it('refuses caller-written resolved_by_id labels and forged brands (WeakSet)', () => { expect( decideEarnedAutonomyWiden({ projectId: 'proj-a', @@ -178,6 +178,28 @@ describe('earned autonomy trust-locks', () => { consumedReceiptIds: [], }), ).toEqual({ ok: false, reason: 'unverified_win_label' }) + + // Cosmetic _brand without registry — same class as ranker forge. + const forged = { + _brand: 'VerifiedWinRef' as const, + receiptId: 'never-resolved', + projectId: 'proj-a', + agentId: 'agent-a', + polarity: 'win' as const, + resolvedAt: '2026-07-27T12:00:00.000Z', + } + expect( + decideEarnedAutonomyWiden({ + projectId: 'proj-a', + agentId: 'agent-a', + current: 'suggest', + proposed: 'draft', + actingPrincipalKind: 'owner_or_admin_human', + wins: [forged, forged, forged], + requiredWins: 3, + consumedReceiptIds: [], + }), + ).toEqual({ ok: false, reason: 'unverified_win_label' }) }) it('widens one step only from resolver-minted wins matching scope', () => { From 2088e4c0c07b386d6d7a8c62415bd783b6e9fabc Mon Sep 17 00:00:00 2001 From: kasra Date: Mon, 27 Jul 2026 18:09:38 +0000 Subject: [PATCH 5/8] fix(owner): measured mode unreachable in v1 by construction + doc sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BLOCK-B residual: measurementMode:'measured' could be produced by any caller string on an allowlisted-but-wrong-scope KpiSourceId (task_counter/github_prs are agent/tenant scoped, not project scoped). Add PROJECT_SCOPED_KPI_SOURCE_IDS (empty in v1) and gate 'measured' on membership, making it unreachable in v1 by construction rather than by convention. Delete the green test that asserted a measured ratio from github_prs; add tests proving 'measured' is unreachable for every allowlisted KpiSourceId, and a retitled test that a non-allowlisted id cast is refused (G7). Doc sync across design.md: #505 is closed-unmerged (not "in review"), Rank maps to the live rankTasks interim (not BrainPort, which is type-only-sealed with zero callers), and §4.2/§4.5/§8 now describe unmeasured_until_project_kpi semantics consistently. §4.2's Outcome diagram matches the shipped TS type including measurementMode. Cheap riders: contract status assertion checks allowlist membership instead of a literal; OWNER_TRUST_LADDER drift-locked against the newly-exported AUTONOMIES; migration 0072 named as next-free; new JSON fields (minRequiredWins, homeRequiredFacets, projectScopedKpiSourceIds, winConstructor) asserted against their TS constants instead of duplicated literals; suggestedBuildOrder documented in §8 prose; added a mutation-style test for the callerKind invalid-enum throw. Co-authored-by: Cursor --- docs/owner-experience-v1.json | 4 +- ...owner-experience-unified-surface-design.md | 86 ++++++++++++++----- src/owner/owner-experience-contract.ts | 18 ++++ src/types.ts | 4 +- tests/owner-experience-contract.test.ts | 86 +++++++++++++++---- 5 files changed, 156 insertions(+), 42 deletions(-) diff --git a/docs/owner-experience-v1.json b/docs/owner-experience-v1.json index 5e4f55fc..de7a77c9 100644 --- a/docs/owner-experience-v1.json +++ b/docs/owner-experience-v1.json @@ -71,7 +71,9 @@ "forbidFakeGreen": true, "v1Mode": "unmeasured_until_project_kpi", "metricSourceIdType": "KpiSourceId", - "sourceWiredCallerBoolean": false + "sourceWiredCallerBoolean": false, + "projectScopedKpiSourceIds": [], + "measuredUnreachableInV1": true }, "unmetDependencies": [ { diff --git a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md index 2ba8cf1c..3f0e26ea 100644 --- a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md +++ b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md @@ -17,9 +17,12 @@ learned. [BYOA harness matrix](./2026-07-23-byoa-harness-support-matrix-design.md) (PR #503), [per-project docs RBAC](./2026-07-23-per-project-docs-rbac-design.md) (PR #507 / #522–526), Tier-2 chat design (`2026-07-27-tier2-stateless-user-chat-design.md`, currently -dyad-gate **BLOCK**), Tier-1 kayhermes panel (PR #505), `ProjectSituation` +dyad-gate **BLOCK**), Tier-1 kayhermes panel (PR #505, **CLOSED unmerged, no +successor — Talk is optional**), `ProjectSituation` (`src/projects/situation.ts`), agent `Autonomy` (`src/types.ts`), -`workflow_receipts` + completion/lessons receipts, BrainPort v1 (rank-only). +`workflow_receipts` + completion/lessons receipts, `rankTasks` +(`src/tasks/ranking.ts`) interim ranking — **not** BrainPort v1, which is +type-only-sealed with zero callers. **Machine contract:** [`docs/owner-experience-v1.json`](../../owner-experience-v1.json) + pure TS [`src/owner/owner-experience-contract.ts`](../../../src/owner/owner-experience-contract.ts). @@ -30,7 +33,7 @@ Today an owner meets **three separate products** that happen to share a pot: | Surface | Intent | Current state (verified 2026-07-27 on this worktree) | |---|---|---| -| **Chat** (talk to mubot) | Intent in, clarification out | Tier-1 kayhermes panel in review (#505). Tier-2 every-member chat is **design-blocked** (ModelPort has no tools; pot monthly budget display-only; result fan-in unsettled). | +| **Chat** (talk to mubot) | Intent in, clarification out | Tier-1 kayhermes panel — PR **#505 CLOSED unmerged, no successor**; Talk is **optional** on owner home until revived or replaced. Tier-2 every-member chat is **design-blocked** (ModelPort has no tools; pot monthly budget display-only; result fan-in unsettled). | | **Docs** (read/edit knowledge) | Shared memory the mubot acts on | Spec #507 drafted; **no dashboard Docs surface** on main. `project_remember` / `project_recall` exist as MCP. | | **Board** (watch it work) | Living picture of work | **Wired:** project detail + `ProjectSituation` (health, blockers, reviews, `next_action`), lifecycle loop (start-gate / circuit-breaker / completion / stall). | @@ -145,20 +148,34 @@ the first viewport. Replace "goal is a sentence" with **Outcome**: +Pure TS contract (`src/owner/owner-experience-contract.ts`) — the shipped shape: + ``` -Outcome { +Outcome { // machine contract statement: string // owner's words ("20 booked calls / month") - metric: KpiSpec | null // how progress is measured; null = unmeasured - owner_principal: string // human owner/admin who set it - set_at: string - version: number // increments only on owner change + metric: OutcomeMetricSpec | null // { sourceId: KpiSourceId; target: number } | null + measurementMode: 'unmeasured_until_project_kpi' | 'measured' } ``` -`KpiSpec` reuses the loop-container idea: named signal + source id. v1 allows -only sources that already have an enforcement/read path (`task_counter`, -`github_prs`, and future connector-backed sources when wired). **Unknown or -unwired source ⇒ progress renders `unmeasured`**, never a guessed green bar. +DB row additionally carries `owner_principal` / `set_at` / `version` (§5) — +those are persistence metadata, not part of the pure-function contract type. + +`KpiSourceId = 'task_counter' | 'github_prs'` — both are **agent/tenant +scoped**, not project scoped (§3.2). v1 Outcome measurement is therefore +always `measurementMode: 'unmeasured_until_project_kpi'`; `metric` may still +name a `KpiSourceId` for display/debugging, but it can never flip +`measurementMode` to `'measured'`. + +`measurementMode: 'measured'` is gated on `metric.sourceId` being a member of +`PROJECT_SCOPED_KPI_SOURCE_IDS`, which is the **empty array in v1**. This +makes `'measured'` **unreachable by construction** — no caller-asserted +`measurementMode` field, no allowlisted `KpiSourceId`, and no combination of +`task_counter` / `github_prs` can ever produce a `{ kind: 'measured' }` +progress bar. The type documents the future: once a real project-scoped KPI +source ships, it is added to that allowlist and `'measured'` becomes +reachable for that source only. **Unknown, unwired, or non-project-scoped +source ⇒ progress renders `unmeasured`**, never a guessed green bar. Only `owner` / `admin` principals may create or change Outcome. Mubot / agent tokens may **propose** a restatement (Talk) but the write path requires the @@ -170,7 +187,7 @@ owner principal — encoded in the contract as `mayMubotRedefineGoal === false`. |---|---|---| | **Goal** | Outcome strip | New structured fields (slice 1) | | **Sense** | Watch situation + metric read | `loadProjectSituation` + KPI source | -| **Rank** | What moves the number next | BrainPort ranking (rank-not-act); learning bias **only after** Port-4+ranker PASS | +| **Rank** | What moves the number next | Live `rankTasks` interim (`src/tasks/ranking.ts`, rank-not-act) — BrainPort default adapter is type-only-sealed with zero callers, not cited as reuse; learning bias **only after** Port-4+ranker PASS | | **Act** | Board tasks / flights through technicians | Existing board + BYOA | | **Receipt** | Win or miss, by receipt id | `workflow_receipts` / verdicts / evidence | | **Learn** | Distill miss→guardrail, win→playbook | v1: surface `lessons_capture` + gate-fail receipts; v2: Port-4 distill | @@ -224,13 +241,22 @@ This deliberately does **not** call Port-4 for instincts. Verification binds to `decideProgressDisplay`: -- If `metric === null` or source unwired → `{ kind: 'unmeasured' }` -- If KPI signal compute fails → `{ kind: 'unavailable', reason }` (not 0%, not 100%) -- If signal ok → `{ kind: 'measured', value, target, ratio }` with ratio clamped - to \[0, 1\] for display but raw values retained for audit -- **Forbidden:** rendering `kind: 'measured'` from task-count alone when the - Outcome metric names an external signal; task_counter is allowed only when - the Outcome explicitly selected it. +- If `outcome.measurementMode === 'unmeasured_until_project_kpi'` → `{ kind: 'unmeasured' }` + (v1 default: `task_counter` / `github_prs` are agent/tenant scoped, not + project scoped — they cannot honestly measure a project Outcome). +- If `metric === null`, or `metric.sourceId` is not a valid `KpiSourceId`, or + `metric.sourceId` is not a member of `PROJECT_SCOPED_KPI_SOURCE_IDS` + (**empty in v1** — so `measurementMode: 'measured'` is unreachable by + construction) → `{ kind: 'unmeasured' }` +- If KPI signal compute fails, or the signal's `sourceId` does not match the + metric's → `{ kind: 'unavailable', reason }` (not 0%, not 100%) +- If signal ok and the source is project-scoped → `{ kind: 'measured', value, target, ratio }` + with ratio clamped to \[0, 1\] for display but raw values retained for audit +- **Forbidden:** rendering `kind: 'measured'` from `task_counter` or + `github_prs` under any circumstances in v1 — both are agent/tenant scoped, + never project scoped, and are excluded from `PROJECT_SCOPED_KPI_SOURCE_IDS` + by construction. There is no caller-asserted `measurementMode` string that + can produce a green bar from an agent-scoped source. ### 4.6 Visible learning @@ -246,8 +272,11 @@ Entries without a receipt id are rejected by the contract helper. No ## 5. Data model (draft — validate in dyad-gate; no migration in this commit) +Next free migration number as of 2026-07-27 (highest on `migrations/` is +`0071_agent_connections.sql`): **`0072`** — slice 1 owns and names it. + ``` --- additive on projects (shape only; slice 1 owns the migration) +-- additive on projects (shape only; slice 1 owns migration 0072) outcome_statement TEXT NOT NULL DEFAULT '' -- may mirror goal initially outcome_metric_json TEXT NULL -- KpiSpec or null outcome_owner TEXT NULL @@ -300,8 +329,19 @@ No second memory store. Docs continue to target the #507 unification with ## 8. Build slices (backlog — dyad-gate each; do not start until this design PASSes) -1. **Outcome model** — structured north-star on `projects` + progress helper - wired to existing KPI sources only; fail closed to `unmeasured`. +**Suggested build order (`docs/owner-experience-v1.json` → `suggestedBuildOrder`): +3 → 5 → 1 → 4 → 2 → 7 → 6 → 8** — trust-strip-gate-queue and visible-learning +ship first because they only read existing substrate (situation reviews, +`lessons_capture`); outcome-model and earned-autonomy follow once those +surfaces exist to hang off; owner-home-shell composes what is already built; +the two gated slices (Tier-2 talk, instinct learn/adapt) stay last behind +their own prerequisite gates. The numbered list below is dependency order for +reading, not build order — do not build 1 → 2 → 3 in sequence. + +1. **Outcome model** — structured north-star on `projects` + progress helper. + v1 mode is `unmeasured_until_project_kpi` by default; `measured` stays + unreachable until a source lands in `PROJECT_SCOPED_KPI_SOURCE_IDS`. Next + free migration number: **0072**. 2. **Owner home shell** — compose Outcome + Trust + Talk/Know/Watch facets on project detail (Watch = existing situation; Talk = Tier-1 embed; Know = memory list). No new cognition. diff --git a/src/owner/owner-experience-contract.ts b/src/owner/owner-experience-contract.ts index 634db8b0..4b882500 100644 --- a/src/owner/owner-experience-contract.ts +++ b/src/owner/owner-experience-contract.ts @@ -56,6 +56,18 @@ export function isKpiSourceId(v: unknown): v is KpiSourceId { return typeof v === 'string' && (KPI_SOURCE_IDS as readonly string[]).includes(v) } +/** + * Sources allowed to produce `measurementMode: 'measured'`. Empty in v1 by + * construction: `task_counter` / `github_prs` are agent/tenant scoped, not + * project scoped, so neither can honestly measure a project Outcome. Adding + * a real project-scoped source later means adding it here — not flipping a + * caller-asserted boolean. See BLOCK-B (owner-experience dyad-gate). + */ +export const PROJECT_SCOPED_KPI_SOURCE_IDS: readonly KpiSourceId[] = [] +export function isProjectScopedKpiSourceId(v: KpiSourceId): boolean { + return (PROJECT_SCOPED_KPI_SOURCE_IDS as readonly string[]).includes(v) +} + /** * Rate/consumption story (named, not implied): * Successful widen returns consumeReceiptIds; those ids must be passed back in @@ -380,6 +392,11 @@ export function decideEarnedAutonomyWiden( * Caller `sourceWired` boolean REMOVED — wired-ness derived from KpiSourceId allowlist. * v1 `unmeasured_until_project_kpi` always returns unmeasured (existing KPI sources * are agent-scoped, not project-owner UX — BLOCK-B). + * `measurementMode: 'measured'` is additionally gated on + * `PROJECT_SCOPED_KPI_SOURCE_IDS` membership, which is empty in v1 — so + * `'measured'` is unreachable by construction, not just by convention. No + * caller-asserted `measurementMode` string can produce a green bar from + * `task_counter` / `github_prs`. */ export function decideProgressDisplay(input: { outcome: Outcome @@ -393,6 +410,7 @@ export function decideProgressDisplay(input: { } if (input.outcome.metric === null) return { kind: 'unmeasured' } if (!isKpiSourceId(input.outcome.metric.sourceId)) return { kind: 'unmeasured' } + if (!isProjectScopedKpiSourceId(input.outcome.metric.sourceId)) return { kind: 'unmeasured' } if (input.signal === null) { return { kind: 'unavailable', reason: 'signal_missing' } } diff --git a/src/types.ts b/src/types.ts index cc118011..4a35bc78 100644 --- a/src/types.ts +++ b/src/types.ts @@ -188,7 +188,9 @@ export type Autonomy = 'suggest' | 'draft' | 'execute' | 'execute_with_approval' export type BudgetWindow = 'day' | 'week' const EFFORTS: readonly Effort[] = ['low', 'standard', 'high', 'sprint'] -const AUTONOMIES: readonly Autonomy[] = ['suggest', 'draft', 'execute', 'execute_with_approval'] +// Exported so consumers (e.g. owner-experience trust ladder) can drift-lock +// against the canonical set instead of hand-maintaining a parallel list. +export const AUTONOMIES: readonly Autonomy[] = ['suggest', 'draft', 'execute', 'execute_with_approval'] const BUDGET_WINDOWS: readonly BudgetWindow[] = ['day', 'week'] export function isEffort(v: unknown): v is Effort { diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts index fa53900f..e7c6795c 100644 --- a/tests/owner-experience-contract.test.ts +++ b/tests/owner-experience-contract.test.ts @@ -1,11 +1,15 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' +import { AUTONOMIES } from '../src/types' import { DEFAULT_REQUIRED_WINS_PER_WIDEN, + MIN_REQUIRED_WINS_PER_WIDEN, OWNER_EXPERIENCE_FACETS, OWNER_EXPERIENCE_LOOP, OWNER_EXPERIENCE_PRINCIPLES, + OWNER_HOME_REQUIRED_FACETS, OWNER_TRUST_LADDER, + PROJECT_SCOPED_KPI_SOURCE_IDS, WIN_CONSUMPTION_POLICY, assertOwnerHomeFacets, assertTalkV1Runtime, @@ -20,14 +24,20 @@ import { trustLevelForAutonomy, validateLessonDraft, verifiedWinRefFromResolver, + type KpiSourceId, + type PermissionCallerKind, type WinReceiptResolver, } from '../src/owner/owner-experience-contract' +/** Design-lifecycle statuses this contract doc may legitimately carry. */ +const ALLOWED_CONTRACT_STATUSES = ['design', 'reviewing', 'ready-for-build', 'approved'] as const + const contract = JSON.parse( readFileSync(new URL('../docs/owner-experience-v1.json', import.meta.url), 'utf8'), ) as { id: string status: string + homeRequiredFacets: string[] facets: string[] loop: string[] principles: string[] @@ -37,11 +47,18 @@ const contract = JSON.parse( earnedAutonomy: { maxStepsPerWiden: number defaultRequiredWins: number + minRequiredWins: number winVerification: string + winConstructor: string mubotSelfWiden: boolean winConsumptionPolicy: string } - progress: { allowedKinds: string[]; forbidFakeGreen: boolean } + progress: { + allowedKinds: string[] + forbidFakeGreen: boolean + projectScopedKpiSourceIds: string[] + measuredUnreachableInV1: boolean + } unmetDependencies: Array<{ id: string; status: string }> nonGoals: string[] buildSlices: string[] @@ -84,7 +101,7 @@ const store: WinReceiptResolver = (id) => { describe('owner-experience/v1 contract doc', () => { it('is the design-status owner-experience contract', () => { expect(contract.id).toBe('owner-experience/v1') - expect(contract.status).toBe('design') + expect(ALLOWED_CONTRACT_STATUSES).toContain(contract.status) expect(contract.talk.v1Runtime).toBe('tier1_persistent_mubot') expect(contract.talk.tier2Status).toBe('dyad-gate-blocked') expect(contract.earnedAutonomy.mubotSelfWiden).toBe(false) @@ -102,13 +119,22 @@ describe('owner-experience/v1 contract doc', () => { it('matches the pure module facets, loop, and trust ladder', () => { expect(contract.facets).toEqual([...OWNER_EXPERIENCE_FACETS]) + expect(contract.homeRequiredFacets).toEqual([...OWNER_HOME_REQUIRED_FACETS]) expect(contract.loop).toEqual([...OWNER_EXPERIENCE_LOOP]) expect(contract.principles).toEqual([...OWNER_EXPERIENCE_PRINCIPLES]) expect(contract.earnedAutonomy.defaultRequiredWins).toBe(DEFAULT_REQUIRED_WINS_PER_WIDEN) + expect(contract.earnedAutonomy.minRequiredWins).toBe(MIN_REQUIRED_WINS_PER_WIDEN) expect(contract.earnedAutonomy.maxStepsPerWiden).toBe(1) + expect(contract.earnedAutonomy.winConstructor).toBe(verifiedWinRefFromResolver.name) expect(contract.trustLevels.map((row) => row.autonomy)).toEqual([...OWNER_TRUST_LADDER]) }) + it('locks measured-unreachable-in-v1 JSON fields against the TS allowlist constant', () => { + expect(contract.progress.projectScopedKpiSourceIds).toEqual([...PROJECT_SCOPED_KPI_SOURCE_IDS]) + expect(PROJECT_SCOPED_KPI_SOURCE_IDS).toEqual([]) + expect(contract.progress.measuredUnreachableInV1).toBe(true) + }) + it('locks the load-bearing invariants and names unmet dependencies', () => { for (const inv of [ 'owner-owns-goal-mubot-cannot-redefine', @@ -144,6 +170,16 @@ describe('owner goal and gate ownership', () => { expect(mayMubotSelfVerdictGate('owner_or_admin_human')).toBe(false) }) + it('throws on an invalid callerKind instead of silently returning false (mutation-proven branch)', () => { + const invalidCallerKind = 'superadmin' as unknown as PermissionCallerKind + expect(() => mayMubotRedefineGoal(invalidCallerKind)).toThrow( + /owner_experience_unknown_caller_kind/, + ) + expect(() => mayMubotSelfVerdictGate(invalidCallerKind)).toThrow( + /owner_experience_unknown_caller_kind/, + ) + }) + it('allows Talk v1 only for Tier-1 mubot runtime', () => { expect(isTalkV1Runtime('tier1_persistent_mubot')).toBe(true) expect(isTalkV1Runtime('tier2_stateless_user_chat')).toBe(false) @@ -161,6 +197,11 @@ describe('earned autonomy trust-locks', () => { expect(autonomyForTrustLevel(2)).toBe('execute_with_approval') }) + it('drift-locks OWNER_TRUST_LADDER against the canonical AUTONOMIES set (order may differ, membership must not)', () => { + expect(OWNER_TRUST_LADDER.length).toBe(AUTONOMIES.length) + expect(new Set(OWNER_TRUST_LADDER)).toEqual(new Set(AUTONOMIES)) + }) + it('refuses caller-written resolved_by_id labels and forged brands (WeakSet)', () => { expect( decideEarnedAutonomyWiden({ @@ -342,27 +383,38 @@ describe('honest progress and visible learning', () => { ).toEqual({ kind: 'unmeasured' }) }) - it('measured path requires allowlisted KpiSourceId and matching signal — no sourceWired boolean', () => { - expect( - decideProgressDisplay({ - outcome: { - statement: 'PRs', - metric: { sourceId: 'github_prs', target: 10 }, - measurementMode: 'measured', - }, - signal: { ok: true, value: 5, sourceId: 'github_prs' }, - }), - ).toEqual({ kind: 'measured', value: 5, target: 10, ratio: 0.5 }) + it('measurementMode "measured" is unreachable in v1 by construction — PROJECT_SCOPED_KPI_SOURCE_IDS is empty', () => { + expect(PROJECT_SCOPED_KPI_SOURCE_IDS.length).toBe(0) + // Every allowlisted KpiSourceId is still agent/tenant scoped, not project + // scoped — a matching signal on an allowlisted source must NOT produce a + // green bar. This is the exact wrong-scope case BLOCK-B was about. + for (const sourceId of ['github_prs', 'task_counter'] as const) { + expect( + decideProgressDisplay({ + outcome: { + statement: 'PRs', + metric: { sourceId, target: 10 }, + measurementMode: 'measured', + }, + signal: { ok: true, value: 5, sourceId }, + }), + ).toEqual({ kind: 'unmeasured' }) + } + }) + + it('requires KpiSourceId allowlist membership (G7) — a non-allowlisted id is refused, not just a wrong-scope one', () => { expect( decideProgressDisplay({ outcome: { - statement: 'PRs', - metric: { sourceId: 'github_prs', target: 10 }, + statement: 'Booked calls', + // Deliberately not a member of KpiSourceId — proves isKpiSourceId + // itself rejects unknown ids, independent of the v1 project-scope gate. + metric: { sourceId: 'ghl_booked_calls' as unknown as KpiSourceId, target: 10 }, measurementMode: 'measured', }, - signal: { ok: true, value: 5, sourceId: 'task_counter' }, + signal: null, }), - ).toEqual({ kind: 'unavailable', reason: 'source_id_mismatch' }) + ).toEqual({ kind: 'unmeasured' }) }) it('requires receipt id on lessons', () => { From e935ace53c56b65d67e0bbca66f8b8eeb4e468b9 Mon Sep 17 00:00:00 2001 From: kasra Date: Mon, 27 Jul 2026 23:22:10 +0000 Subject: [PATCH 6/8] fix(owner): lock KPI allowlist contents and freeze arrays Direct isKpiSourceId + KPI_SOURCE_IDS contents assertions replace a false comment claiming independence from the project-scope gate. Freeze both allowlists so "unreachable by construction" is not defeasible by push. Co-authored-by: Cursor --- src/owner/owner-experience-contract.ts | 10 ++++++++-- tests/owner-experience-contract.test.ts | 12 +++++++++--- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/owner/owner-experience-contract.ts b/src/owner/owner-experience-contract.ts index 4b882500..112d2b08 100644 --- a/src/owner/owner-experience-contract.ts +++ b/src/owner/owner-experience-contract.ts @@ -51,7 +51,10 @@ export const DEFAULT_REQUIRED_WINS_PER_WIDEN = 3 export const MIN_REQUIRED_WINS_PER_WIDEN = 3 export type KpiSourceId = 'task_counter' | 'github_prs' -export const KPI_SOURCE_IDS: readonly KpiSourceId[] = ['task_counter', 'github_prs'] +export const KPI_SOURCE_IDS: readonly KpiSourceId[] = Object.freeze([ + 'task_counter', + 'github_prs', +] as const satisfies readonly KpiSourceId[]) export function isKpiSourceId(v: unknown): v is KpiSourceId { return typeof v === 'string' && (KPI_SOURCE_IDS as readonly string[]).includes(v) } @@ -62,8 +65,11 @@ export function isKpiSourceId(v: unknown): v is KpiSourceId { * project scoped, so neither can honestly measure a project Outcome. Adding * a real project-scoped source later means adding it here — not flipping a * caller-asserted boolean. See BLOCK-B (owner-experience dyad-gate). + * Frozen so "unreachable by construction" is not defeasible by array push. */ -export const PROJECT_SCOPED_KPI_SOURCE_IDS: readonly KpiSourceId[] = [] +export const PROJECT_SCOPED_KPI_SOURCE_IDS: readonly KpiSourceId[] = Object.freeze( + [] as const satisfies readonly KpiSourceId[], +) export function isProjectScopedKpiSourceId(v: KpiSourceId): boolean { return (PROJECT_SCOPED_KPI_SOURCE_IDS as readonly string[]).includes(v) } diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts index e7c6795c..c0d35203 100644 --- a/tests/owner-experience-contract.test.ts +++ b/tests/owner-experience-contract.test.ts @@ -9,8 +9,10 @@ import { OWNER_EXPERIENCE_PRINCIPLES, OWNER_HOME_REQUIRED_FACETS, OWNER_TRUST_LADDER, + KPI_SOURCE_IDS, PROJECT_SCOPED_KPI_SOURCE_IDS, WIN_CONSUMPTION_POLICY, + isKpiSourceId, assertOwnerHomeFacets, assertTalkV1Runtime, autonomyForTrustLevel, @@ -133,6 +135,7 @@ describe('owner-experience/v1 contract doc', () => { expect(contract.progress.projectScopedKpiSourceIds).toEqual([...PROJECT_SCOPED_KPI_SOURCE_IDS]) expect(PROJECT_SCOPED_KPI_SOURCE_IDS).toEqual([]) expect(contract.progress.measuredUnreachableInV1).toBe(true) + expect(contract.progress.v1Mode).toBe('unmeasured_until_project_kpi') }) it('locks the load-bearing invariants and names unmet dependencies', () => { @@ -402,13 +405,16 @@ describe('honest progress and visible learning', () => { } }) - it('requires KpiSourceId allowlist membership (G7) — a non-allowlisted id is refused, not just a wrong-scope one', () => { + it('isKpiSourceId locks allowlist contents (mutation-detectable)', () => { + expect(isKpiSourceId('ghl_booked_calls')).toBe(false) + expect([...KPI_SOURCE_IDS]).toEqual(['task_counter', 'github_prs']) + }) + + it('decideProgressDisplay returns unmeasured for a cast non-allowlisted sourceId', () => { expect( decideProgressDisplay({ outcome: { statement: 'Booked calls', - // Deliberately not a member of KpiSourceId — proves isKpiSourceId - // itself rejects unknown ids, independent of the v1 project-scope gate. metric: { sourceId: 'ghl_booked_calls' as unknown as KpiSourceId, target: 10 }, measurementMode: 'measured', }, From fe9cf44fd478d72c2d2e74dba04aece755e1909d Mon Sep 17 00:00:00 2001 From: kasra Date: Mon, 27 Jul 2026 23:41:49 +0000 Subject: [PATCH 7/8] feat(owner): wire contract-assert-policy + freeze AUTONOMIES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files in this head: - src/contracts/contract-assert-policy.ts (new — approved helper) - tests/owner-experience-contract.test.ts (real-suite policy + isFrozen locks) - src/types.ts (Object.freeze(AUTONOMIES)) - docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md (slice-1 brief: ship four measured-branch tests with first project-scoped source) Co-authored-by: Cursor --- ...owner-experience-unified-surface-design.md | 9 +- src/contracts/contract-assert-policy.ts | 252 ++++++++++++++++++ src/types.ts | 7 +- tests/owner-experience-contract.test.ts | 55 ++++ 4 files changed, 320 insertions(+), 3 deletions(-) create mode 100644 src/contracts/contract-assert-policy.ts diff --git a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md index 3f0e26ea..13a51266 100644 --- a/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md +++ b/docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md @@ -340,8 +340,13 @@ reading, not build order — do not build 1 → 2 → 3 in sequence. 1. **Outcome model** — structured north-star on `projects` + progress helper. v1 mode is `unmeasured_until_project_kpi` by default; `measured` stays - unreachable until a source lands in `PROJECT_SCOPED_KPI_SOURCE_IDS`. Next - free migration number: **0072**. + unreachable until a source lands in `PROJECT_SCOPED_KPI_SOURCE_IDS`. The + first project-scoped allowlist entry MUST ship WITH the four downstream + branch tests for `decideProgressDisplay` once `measured` is reachable + (`signal_missing`, signal `ok:false`, `source_id_mismatch`, + `invalid_target` / measured ratio) — those branches are dead under the + empty v1 allowlist and must not be left untested when the gate opens. + Next free migration number: **0072**. 2. **Owner home shell** — compose Outcome + Trust + Talk/Know/Watch facets on project detail (Watch = existing situation; Talk = Tier-1 embed; Know = memory list). No new cognition. diff --git a/src/contracts/contract-assert-policy.ts b/src/contracts/contract-assert-policy.ts new file mode 100644 index 00000000..f402f86c --- /dev/null +++ b/src/contracts/contract-assert-policy.ts @@ -0,0 +1,252 @@ +// Machine-checkable contract policy (Kasra rules): +// 1. Lifecycle fields (status/mode/phase) — membership in declared allowedValues + whoMayFlip; never single-literal equality in tests. +// 2. JSON↔TS mirrors — assert JSON === TS export; never retyped literals in tests. + +export type LifecycleFieldName = 'status' | 'mode' | 'phase' + +export interface LifecycleFieldDeclaration { + field: LifecycleFieldName + allowedValues: readonly string[] + whoMayFlip: readonly string[] +} + +export interface JsonTsMirrorDeclaration { + jsonPath: readonly string[] + tsExportName: string +} + +export interface ContractAssertPolicySpec { + lifecycleFields: readonly LifecycleFieldDeclaration[] + jsonTsMirrors: readonly JsonTsMirrorDeclaration[] +} + +export type PolicyFindingCode = + | 'lifecycle_missing_allowed_values' + | 'lifecycle_missing_who_may_flip' + | 'lifecycle_value_not_in_allowed_set' + | 'lifecycle_test_literal_equality' + | 'json_ts_mirror_missing_ts_constant' + | 'json_ts_mirror_value_mismatch' + | 'json_ts_mirror_literal_in_test' + | 'json_ts_mirror_missing_test_assertion' + +export interface PolicyFinding { + code: PolicyFindingCode + message: string + jsonPath?: string + tsExportName?: string +} + +function jsonPathKey(path: readonly string[]): string { + return path.join('.') +} + +function contractAccessPath(jsonPath: readonly string[]): string { + if (jsonPath.length === 0) { + return 'contract' + } + return `contract.${jsonPath.join('.')}` +} + +export function getJsonValue( + root: Record, + path: readonly string[], +): unknown { + let current: unknown = root + for (const key of path) { + if (typeof current !== 'object' || current === null) { + return undefined + } + const bag = current as Record + if (!(key in bag)) { + return undefined + } + current = bag[key] + } + return current +} + +function stableEqual(left: unknown, right: unknown): boolean { + return JSON.stringify(left) === JSON.stringify(right) +} + +export function checkLifecycleDeclarations( + spec: ContractAssertPolicySpec, +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const row of spec.lifecycleFields) { + if (row.allowedValues.length === 0) { + findings.push({ + code: 'lifecycle_missing_allowed_values', + message: `lifecycle field "${row.field}" must declare a non-empty allowedValues set`, + jsonPath: row.field, + }) + } + if (row.whoMayFlip.length === 0) { + findings.push({ + code: 'lifecycle_missing_who_may_flip', + message: `lifecycle field "${row.field}" must declare whoMayFlip`, + jsonPath: row.field, + }) + } + } + return findings +} + +export function checkLifecycleValues( + spec: ContractAssertPolicySpec, + contractJson: Record, +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const row of spec.lifecycleFields) { + const value = contractJson[row.field] + if (typeof value !== 'string') { + findings.push({ + code: 'lifecycle_value_not_in_allowed_set', + message: `lifecycle field "${row.field}" must be a string present in allowedValues`, + jsonPath: row.field, + }) + continue + } + if (!(row.allowedValues as readonly string[]).includes(value)) { + findings.push({ + code: 'lifecycle_value_not_in_allowed_set', + message: `lifecycle value "${value}" for "${row.field}" is not in declared allowedValues`, + jsonPath: row.field, + }) + } + } + return findings +} + +export function checkJsonTsMirrors( + spec: ContractAssertPolicySpec, + contractJson: Record, + tsExports: Record, +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const mirror of spec.jsonTsMirrors) { + const pathLabel = jsonPathKey(mirror.jsonPath) + if (!(mirror.tsExportName in tsExports)) { + findings.push({ + code: 'json_ts_mirror_missing_ts_constant', + message: `mirror "${pathLabel}" requires TS export "${mirror.tsExportName}"`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + continue + } + const jsonValue = getJsonValue(contractJson, mirror.jsonPath) + const tsValue = tsExports[mirror.tsExportName] + const normalizedTs = + Array.isArray(tsValue) && Array.isArray(jsonValue) + ? [...tsValue] + : tsValue + if (!stableEqual(jsonValue, normalizedTs)) { + findings.push({ + code: 'json_ts_mirror_value_mismatch', + message: `mirror "${pathLabel}" JSON value does not match TS export "${mirror.tsExportName}"`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + } + } + return findings +} + +function lifecycleLiteralEqualityPattern(field: LifecycleFieldName): RegExp { + return new RegExp( + String.raw`expect\s*\(\s*contract\.${field}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*['"\`]`, + 'g', + ) +} + +export function checkTestSourceLifecycle( + testSource: string, + lifecycleFields: readonly LifecycleFieldDeclaration[], +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const row of lifecycleFields) { + const matches = testSource.match(lifecycleLiteralEqualityPattern(row.field)) + if (matches !== null && matches.length > 0) { + findings.push({ + code: 'lifecycle_test_literal_equality', + message: `test asserts contract.${row.field} via single-literal equality; use membership in declared allowedValues instead`, + jsonPath: row.field, + }) + } + } + return findings +} + +function mirrorLiteralPattern(accessPath: string): RegExp { + const escaped = accessPath.replace(/\./g, '\\.') + return new RegExp( + String.raw`expect\s*\(\s*${escaped}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*(?!\[\.\.\.)(?:['"\`]|(?:\[))`, + 'g', + ) +} + +function mirrorUsesTsConstant(testSource: string, accessPath: string, tsExportName: string): boolean { + const escapedPath = accessPath.replace(/\./g, '\\.') + const spreadPattern = new RegExp( + String.raw`expect\s*\(\s*${escapedPath}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*\[\.\.\.${tsExportName}\]`, + ) + const directPattern = new RegExp( + String.raw`expect\s*\(\s*${escapedPath}\s*\)\s*\.(?:toBe|toEqual)\s*\(\s*${tsExportName}\b`, + ) + return spreadPattern.test(testSource) || directPattern.test(testSource) +} + +export function checkTestSourceMirrors( + testSource: string, + mirrors: readonly JsonTsMirrorDeclaration[], +): PolicyFinding[] { + const findings: PolicyFinding[] = [] + for (const mirror of mirrors) { + const accessPath = contractAccessPath(mirror.jsonPath) + const pathLabel = jsonPathKey(mirror.jsonPath) + if (!mirrorUsesTsConstant(testSource, accessPath, mirror.tsExportName)) { + findings.push({ + code: 'json_ts_mirror_missing_test_assertion', + message: `test must assert ${accessPath} against TS export "${mirror.tsExportName}" (spread array or direct .toBe)`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + } + const literalHits = testSource.match(mirrorLiteralPattern(accessPath)) + if (literalHits !== null && literalHits.length > 0) { + findings.push({ + code: 'json_ts_mirror_literal_in_test', + message: `test retypes a literal for ${accessPath}; assert against "${mirror.tsExportName}" instead`, + jsonPath: pathLabel, + tsExportName: mirror.tsExportName, + }) + } + } + return findings +} + +export interface ContractAssertPolicyInput { + spec: ContractAssertPolicySpec + contractJson: Record + tsExports: Record + testSource: string +} + +export function runContractAssertPolicy(input: ContractAssertPolicyInput): PolicyFinding[] { + return [ + ...checkLifecycleDeclarations(input.spec), + ...checkLifecycleValues(input.spec, input.contractJson), + ...checkJsonTsMirrors(input.spec, input.contractJson, input.tsExports), + ...checkTestSourceLifecycle(input.testSource, input.spec.lifecycleFields), + ...checkTestSourceMirrors(input.testSource, input.spec.jsonTsMirrors), + ] +} + +export function policyFindingsInclude( + findings: readonly PolicyFinding[], + code: PolicyFindingCode, +): boolean { + return findings.some((row) => row.code === code) +} diff --git a/src/types.ts b/src/types.ts index 4a35bc78..227f5828 100644 --- a/src/types.ts +++ b/src/types.ts @@ -190,7 +190,12 @@ export type BudgetWindow = 'day' | 'week' const EFFORTS: readonly Effort[] = ['low', 'standard', 'high', 'sprint'] // Exported so consumers (e.g. owner-experience trust ladder) can drift-lock // against the canonical set instead of hand-maintaining a parallel list. -export const AUTONOMIES: readonly Autonomy[] = ['suggest', 'draft', 'execute', 'execute_with_approval'] +export const AUTONOMIES: readonly Autonomy[] = Object.freeze([ + 'suggest', + 'draft', + 'execute', + 'execute_with_approval', +] as const satisfies readonly Autonomy[]) const BUDGET_WINDOWS: readonly BudgetWindow[] = ['day', 'week'] export function isEffort(v: unknown): v is Effort { diff --git a/tests/owner-experience-contract.test.ts b/tests/owner-experience-contract.test.ts index c0d35203..8ce7aa14 100644 --- a/tests/owner-experience-contract.test.ts +++ b/tests/owner-experience-contract.test.ts @@ -30,6 +30,7 @@ import { type PermissionCallerKind, type WinReceiptResolver, } from '../src/owner/owner-experience-contract' +import { runContractAssertPolicy } from '../src/contracts/contract-assert-policy' /** Design-lifecycle statuses this contract doc may legitimately carry. */ const ALLOWED_CONTRACT_STATUSES = ['design', 'reviewing', 'ready-for-build', 'approved'] as const @@ -60,6 +61,7 @@ const contract = JSON.parse( forbidFakeGreen: boolean projectScopedKpiSourceIds: string[] measuredUnreachableInV1: boolean + v1Mode: string } unmetDependencies: Array<{ id: string; status: string }> nonGoals: string[] @@ -134,10 +136,63 @@ describe('owner-experience/v1 contract doc', () => { it('locks measured-unreachable-in-v1 JSON fields against the TS allowlist constant', () => { expect(contract.progress.projectScopedKpiSourceIds).toEqual([...PROJECT_SCOPED_KPI_SOURCE_IDS]) expect(PROJECT_SCOPED_KPI_SOURCE_IDS).toEqual([]) + expect(Object.isFrozen(KPI_SOURCE_IDS)).toBe(true) + expect(Object.isFrozen(PROJECT_SCOPED_KPI_SOURCE_IDS)).toBe(true) + expect(Object.isFrozen(AUTONOMIES)).toBe(true) expect(contract.progress.measuredUnreachableInV1).toBe(true) expect(contract.progress.v1Mode).toBe('unmeasured_until_project_kpi') }) + it('enforces contract-assert-policy against the real JSON + suite (not a fixture)', () => { + const testSource = readFileSync(new URL(import.meta.url), 'utf8') + const findings = runContractAssertPolicy({ + spec: { + lifecycleFields: [ + { + field: 'status', + allowedValues: ALLOWED_CONTRACT_STATUSES, + whoMayFlip: ['loom', 'kasra', 'athena-gate'], + }, + ], + jsonTsMirrors: [ + { jsonPath: ['facets'], tsExportName: 'OWNER_EXPERIENCE_FACETS' }, + { jsonPath: ['homeRequiredFacets'], tsExportName: 'OWNER_HOME_REQUIRED_FACETS' }, + { jsonPath: ['loop'], tsExportName: 'OWNER_EXPERIENCE_LOOP' }, + { jsonPath: ['principles'], tsExportName: 'OWNER_EXPERIENCE_PRINCIPLES' }, + { + jsonPath: ['earnedAutonomy', 'defaultRequiredWins'], + tsExportName: 'DEFAULT_REQUIRED_WINS_PER_WIDEN', + }, + { + jsonPath: ['earnedAutonomy', 'minRequiredWins'], + tsExportName: 'MIN_REQUIRED_WINS_PER_WIDEN', + }, + { + jsonPath: ['earnedAutonomy', 'winConsumptionPolicy'], + tsExportName: 'WIN_CONSUMPTION_POLICY', + }, + { + jsonPath: ['progress', 'projectScopedKpiSourceIds'], + tsExportName: 'PROJECT_SCOPED_KPI_SOURCE_IDS', + }, + ], + }, + contractJson: contract as unknown as Record, + tsExports: { + OWNER_EXPERIENCE_FACETS, + OWNER_HOME_REQUIRED_FACETS, + OWNER_EXPERIENCE_LOOP, + OWNER_EXPERIENCE_PRINCIPLES, + DEFAULT_REQUIRED_WINS_PER_WIDEN, + MIN_REQUIRED_WINS_PER_WIDEN, + WIN_CONSUMPTION_POLICY, + PROJECT_SCOPED_KPI_SOURCE_IDS, + }, + testSource, + }) + expect(findings).toEqual([]) + }) + it('locks the load-bearing invariants and names unmet dependencies', () => { for (const inv of [ 'owner-owns-goal-mubot-cannot-redefine', From da9f144f07e112697d624dd3d1f7bbd03019acaa Mon Sep 17 00:00:00 2001 From: kasra Date: Tue, 28 Jul 2026 00:04:39 +0000 Subject: [PATCH 8/8] fix(owner): bring policy self-test + token-absent lint rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files: - src/contracts/contract-assert-policy.ts — blob-identical to policy lane - tests/contract-assert-policy.test.ts + fixtures (was left behind) - docs/contract-assert-policy.md — lint-not-enforcement + blob ownership Co-authored-by: Cursor --- docs/contract-assert-policy.md | 35 +++ src/contracts/contract-assert-policy.ts | 11 +- tests/contract-assert-policy.test.ts | 288 ++++++++++++++++++ .../owner-experience-v1.json | 160 ++++++++++ 4 files changed, 491 insertions(+), 3 deletions(-) create mode 100644 docs/contract-assert-policy.md create mode 100644 tests/contract-assert-policy.test.ts create mode 100644 tests/fixtures/contract-assert-policy/owner-experience-v1.json diff --git a/docs/contract-assert-policy.md b/docs/contract-assert-policy.md new file mode 100644 index 00000000..f80a0f24 --- /dev/null +++ b/docs/contract-assert-policy.md @@ -0,0 +1,35 @@ +# Contract assert policy + +**What this is:** a machine-checkable **lint** for mupot machine contracts +(JSON + TS module + vitest suite source text). It is **not** enforcement — +a motivated builder can step around it in one or two lines (omit a mirror +declaration with its assertion, comment out an assertion token, or alias a +lifecycle literal). Do not describe it as a production gate. + +**Owner of `src/contracts/contract-assert-policy.ts`:** this branch +(`cursor/contract-assert-policy`). Consumer lanes (owner-experience, +brain-learning-ranker, …) must copy the blob verbatim and must not diverge. +Divergent edits land without merge conflicts and silently weaken other +consumers — pick this owner before a third adopter. + +## Rules + +1. **Lifecycle fields** (`status`, `mode`, `phase`): the policy spec must declare `allowedValues` and `whoMayFlip`. Contract JSON values must be members of `allowedValues`. Tests must **not** use `expect(contract.status).toBe('literal')` — assert membership in the declared set instead. `whoMayFlip` length-only checks are decorative unless the consumer also drift-locks the contents against a design doc / frozen TS constant. + +2. **JSON↔TS mirrors**: mirrored JSON paths must match exported TS constants. Tests must assert `expect(contract.path).toEqual([...TS_CONSTANT])` or `.toBe(TS_CONSTANT)` — never retype literals (`['talk', 'know']`) in the test. Missing TS export is a finding. Finding code `json_ts_mirror_assertion_token_absent` means the suite **source text** lacks the assertion token — commented-out / `it.skip` / dead-branch text still counts. Rename honesty: this is token presence, not "assertion runs". + +## Usage + +```typescript +import { runContractAssertPolicy } from '../src/contracts/contract-assert-policy' + +const findings = runContractAssertPolicy({ + spec: { lifecycleFields, jsonTsMirrors }, + contractJson, + tsExports, + testSource, +}) +expect(findings).toEqual([]) +``` + +Implementation: `src/contracts/contract-assert-policy.ts`. Self-test (all finding codes + mutation battery): `tests/contract-assert-policy.test.ts`. diff --git a/src/contracts/contract-assert-policy.ts b/src/contracts/contract-assert-policy.ts index f402f86c..3a3d3c9a 100644 --- a/src/contracts/contract-assert-policy.ts +++ b/src/contracts/contract-assert-policy.ts @@ -28,7 +28,9 @@ export type PolicyFindingCode = | 'json_ts_mirror_missing_ts_constant' | 'json_ts_mirror_value_mismatch' | 'json_ts_mirror_literal_in_test' - | 'json_ts_mirror_missing_test_assertion' + // Token-presence lint only: regex over suite source. Commented-out / + // it.skip / dead-branch text still "count". Not a behavioral enforcement. + | 'json_ts_mirror_assertion_token_absent' export interface PolicyFinding { code: PolicyFindingCode @@ -208,8 +210,11 @@ export function checkTestSourceMirrors( const pathLabel = jsonPathKey(mirror.jsonPath) if (!mirrorUsesTsConstant(testSource, accessPath, mirror.tsExportName)) { findings.push({ - code: 'json_ts_mirror_missing_test_assertion', - message: `test must assert ${accessPath} against TS export "${mirror.tsExportName}" (spread array or direct .toBe)`, + code: 'json_ts_mirror_assertion_token_absent', + message: + `suite source text lacks an assertion-token for ${accessPath} vs "${mirror.tsExportName}" ` + + `(spread/direct .toBe/.toEqual). This is a text-presence lint — comments and ` + + `it.skip still match; it does not prove the assertion runs.`, jsonPath: pathLabel, tsExportName: mirror.tsExportName, }) diff --git a/tests/contract-assert-policy.test.ts b/tests/contract-assert-policy.test.ts new file mode 100644 index 00000000..2a78db55 --- /dev/null +++ b/tests/contract-assert-policy.test.ts @@ -0,0 +1,288 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { + checkJsonTsMirrors, + checkLifecycleDeclarations, + checkLifecycleValues, + checkTestSourceLifecycle, + checkTestSourceMirrors, + getJsonValue, + policyFindingsInclude, + runContractAssertPolicy, + type ContractAssertPolicySpec, + type PolicyFindingCode, +} from '../src/contracts/contract-assert-policy' + +const ownerFixtureJson = JSON.parse( + readFileSync(new URL('./fixtures/contract-assert-policy/owner-experience-v1.json', import.meta.url), 'utf8'), +) as Record + +const OWNER_EXPERIENCE_FACETS = ['talk', 'know', 'watch'] as const +const OWNER_EXPERIENCE_LOOP = [ + 'goal', + 'sense', + 'rank', + 'act', + 'receipt', + 'learn', + 'adapt', + 'gate', +] as const +const OWNER_EXPERIENCE_PRINCIPLES = [ + 'one-face', + 'goals-not-tasks', + 'honest-by-construction', + 'earned-autonomy', + 'visible-learning', + 'owner-owns-goals-and-gates', +] as const +const OWNER_TRUST_LADDER = ['suggest', 'draft', 'execute_with_approval', 'execute'] as const +const DEFAULT_REQUIRED_WINS_PER_WIDEN = 3 +const WIN_CONSUMPTION_POLICY = 'mark_consumed_after_successful_widen' as const + +const ownerSpec: ContractAssertPolicySpec = { + lifecycleFields: [ + { + field: 'status', + allowedValues: ['design', 'dyad-gate', 'live'], + whoMayFlip: ['loom', 'kasra', 'athena-gate'], + }, + ], + jsonTsMirrors: [ + { jsonPath: ['facets'], tsExportName: 'OWNER_EXPERIENCE_FACETS' }, + { jsonPath: ['loop'], tsExportName: 'OWNER_EXPERIENCE_LOOP' }, + { jsonPath: ['principles'], tsExportName: 'OWNER_EXPERIENCE_PRINCIPLES' }, + { + jsonPath: ['earnedAutonomy', 'defaultRequiredWins'], + tsExportName: 'DEFAULT_REQUIRED_WINS_PER_WIDEN', + }, + { + jsonPath: ['earnedAutonomy', 'winConsumptionPolicy'], + tsExportName: 'WIN_CONSUMPTION_POLICY', + }, + ], +} + +const ownerTsExports: Record = { + OWNER_EXPERIENCE_FACETS, + OWNER_EXPERIENCE_LOOP, + OWNER_EXPERIENCE_PRINCIPLES, + DEFAULT_REQUIRED_WINS_PER_WIDEN, + WIN_CONSUMPTION_POLICY, +} + +const compliantOwnerTestSource = ` +describe('owner-experience/v1 contract doc', () => { + it('matches the pure module facets, loop, and trust ladder', () => { + const allowedStatuses = ['design', 'dyad-gate', 'live'] + expect(allowedStatuses).toContain(contract.status) + expect(contract.facets).toEqual([...OWNER_EXPERIENCE_FACETS]) + expect(contract.loop).toEqual([...OWNER_EXPERIENCE_LOOP]) + expect(contract.principles).toEqual([...OWNER_EXPERIENCE_PRINCIPLES]) + expect(contract.earnedAutonomy.defaultRequiredWins).toBe(DEFAULT_REQUIRED_WINS_PER_WIDEN) + expect(contract.earnedAutonomy.winConsumptionPolicy).toBe(WIN_CONSUMPTION_POLICY) + expect(contract.trustLevels.map((row) => row.autonomy)).toEqual([...OWNER_TRUST_LADDER]) + }) +}) +` + +function expectFinding( + findings: readonly { code: PolicyFindingCode }[], + code: PolicyFindingCode, +): void { + expect(policyFindingsInclude(findings, code)).toBe(true) +} + +describe('contract-assert-policy helpers', () => { + it('reads nested JSON paths', () => { + expect(getJsonValue(ownerFixtureJson, ['earnedAutonomy', 'winConsumptionPolicy'])).toBe( + WIN_CONSUMPTION_POLICY, + ) + }) + + it('passes on compliant owner-experience fixture + test source', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: compliantOwnerTestSource, + }) + expect(findings).toEqual([]) + }) +}) + +describe('rule 1 — lifecycle membership, not literal equality', () => { + it('flags missing allowedValues or whoMayFlip in spec', () => { + expectFinding( + checkLifecycleDeclarations({ + lifecycleFields: [ + { + field: 'status', + allowedValues: [], + whoMayFlip: ['loom'], + }, + ], + jsonTsMirrors: [], + }), + 'lifecycle_missing_allowed_values', + ) + expectFinding( + checkLifecycleDeclarations({ + lifecycleFields: [ + { + field: 'phase', + allowedValues: ['design'], + whoMayFlip: [], + }, + ], + jsonTsMirrors: [], + }), + 'lifecycle_missing_who_may_flip', + ) + }) + + it('flags contract lifecycle values outside declared allowed set', () => { + const findings = checkLifecycleValues( + { + lifecycleFields: [ + { + field: 'status', + allowedValues: ['design', 'live'], + whoMayFlip: ['loom'], + }, + ], + jsonTsMirrors: [], + }, + { ...ownerFixtureJson, status: 'shipped-without-gate' }, + ) + expectFinding(findings, 'lifecycle_value_not_in_allowed_set') + }) + + it('flags single-literal lifecycle assertions in test source', () => { + const badTest = ` + it('bad', () => { + expect(contract.status).toBe('design') + }) + ` + const findings = checkTestSourceLifecycle(badTest, ownerSpec.lifecycleFields) + expectFinding(findings, 'lifecycle_test_literal_equality') + }) +}) + +describe('rule 2 — JSON↔TS mirrors via TS exports', () => { + it('flags missing TS export for a declared mirror', () => { + const findings = checkJsonTsMirrors( + { + lifecycleFields: [], + jsonTsMirrors: [{ jsonPath: ['facets'], tsExportName: 'MISSING_FACETS' }], + }, + ownerFixtureJson, + ownerTsExports, + ) + expectFinding(findings, 'json_ts_mirror_missing_ts_constant') + }) + + it('flags JSON value drift from TS export', () => { + const findings = checkJsonTsMirrors(ownerSpec, ownerFixtureJson, { + ...ownerTsExports, + WIN_CONSUMPTION_POLICY: 'never_consume_wins', + }) + expectFinding(findings, 'json_ts_mirror_value_mismatch') + }) + + it('flags retyped literals and missing TS assertion in tests', () => { + const literalTest = ` + expect(contract.facets).toEqual(['talk', 'know', 'watch']) + ` + const findings = checkTestSourceMirrors(literalTest, ownerSpec.jsonTsMirrors) + expectFinding(findings, 'json_ts_mirror_literal_in_test') + expectFinding(findings, 'json_ts_mirror_assertion_token_absent') + + const missingMirrorTest = ` + expect(contract.loop).toEqual(['goal', 'sense']) + ` + const missingFindings = checkTestSourceMirrors(missingMirrorTest, [ + { jsonPath: ['loop'], tsExportName: 'OWNER_EXPERIENCE_LOOP' }, + ]) + expectFinding(missingFindings, 'json_ts_mirror_literal_in_test') + }) +}) + +describe('mutation — deliberate fixture breaks must fire the checker', () => { + it('mutation A: lifecycle literal equality in test', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: `expect(contract.status).toBe('design')`, + }) + expectFinding(findings, 'lifecycle_test_literal_equality') + }) + + it('mutation B: lifecycle value outside allowed set', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: { ...ownerFixtureJson, status: 'production' }, + tsExports: ownerTsExports, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'lifecycle_value_not_in_allowed_set') + }) + + it('mutation C: missing whoMayFlip declaration', () => { + const findings = runContractAssertPolicy({ + spec: { + ...ownerSpec, + lifecycleFields: [ + { + field: 'status', + allowedValues: ['design'], + whoMayFlip: [], + }, + ], + }, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'lifecycle_missing_who_may_flip') + }) + + it('mutation D: missing TS constant for mirror', () => { + const { OWNER_EXPERIENCE_FACETS: _drop, ...partialExports } = ownerTsExports + void _drop + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: partialExports, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'json_ts_mirror_missing_ts_constant') + }) + + it('mutation E: retyped literal mirror assertion in test', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: ownerTsExports, + testSource: ` + expect(['design']).toContain(contract.status) + expect(contract.facets).toEqual(['talk', 'know', 'watch']) + `, + }) + expectFinding(findings, 'json_ts_mirror_literal_in_test') + }) + + it('mutation F: JSON/TS drift on mirrored constant', () => { + const findings = runContractAssertPolicy({ + spec: ownerSpec, + contractJson: ownerFixtureJson, + tsExports: { + ...ownerTsExports, + DEFAULT_REQUIRED_WINS_PER_WIDEN: 1, + }, + testSource: compliantOwnerTestSource, + }) + expectFinding(findings, 'json_ts_mirror_value_mismatch') + }) +}) diff --git a/tests/fixtures/contract-assert-policy/owner-experience-v1.json b/tests/fixtures/contract-assert-policy/owner-experience-v1.json new file mode 100644 index 00000000..5e4f55fc --- /dev/null +++ b/tests/fixtures/contract-assert-policy/owner-experience-v1.json @@ -0,0 +1,160 @@ +{ + "id": "owner-experience/v1", + "status": "design", + "spec": "docs/superpowers/specs/2026-07-27-owner-experience-unified-surface-design.md", + "thesis": "Owner owns the goal and the gate; the mubot owns execution and adaptation; one relationship with Talk, Know, and Watch facets bound to a single project outcome.", + "artifactRef": "4cb8d84f-b5b9-4a55-a47c-3af193161b19", + "facets": ["talk", "know", "watch"], + "homeRequiredFacets": ["know", "watch"], + "talkOptionalUntil": "tier1-persistent-mubot-505-revived", + "loop": [ + "goal", + "sense", + "rank", + "act", + "receipt", + "learn", + "adapt", + "gate" + ], + "principles": [ + "one-face", + "goals-not-tasks", + "honest-by-construction", + "earned-autonomy", + "visible-learning", + "owner-owns-goals-and-gates" + ], + "invariants": [ + "owner-owns-goal-mubot-cannot-redefine", + "owner-owns-gate-mubot-cannot-self-verdict", + "receipts-resolved-by-id-not-label", + "progress-never-fake-green", + "earned-autonomy-one-step-with-verified-wins", + "earned-autonomy-wins-consumed-after-widen", + "earned-autonomy-scope-bound-to-project-and-agent", + "lesson-requires-receipt-id", + "talk-v1-tier1-mubot-only-when-present", + "learn-adapt-instinct-requires-port4-prerequisite", + "brain-remains-rank-not-act", + "mechanism-lock-ne-trust-lock" + ], + "talk": { + "v1Runtime": "tier1_persistent_mubot", + "tier1Status": "closed-unmerged-pr-505", + "tier2Status": "dyad-gate-blocked", + "tier2AllowedOnlyAfter": "tier2-user-chat/v1-pass", + "homePolicy": "optional-facet-until-tier1-revived" + }, + "trustLevels": [ + { "level": 0, "autonomy": "suggest" }, + { "level": 1, "autonomy": "draft" }, + { "level": 2, "autonomy": "execute_with_approval" }, + { "level": 3, "autonomy": "execute" } + ], + "earnedAutonomy": { + "maxStepsPerWiden": 1, + "defaultRequiredWins": 3, + "minRequiredWins": 3, + "winVerification": "branded-VerifiedWinRef-from-WinReceiptResolver-WeakSet-registry", + "winConstructor": "verifiedWinRefFromResolver", + "trustBoundary": "injected-WinReceiptResolver-plus-WeakSet-like-ranker-VerifiedReceiptRef", + "winConsumptionPolicy": "mark_consumed_after_successful_widen", + "dedupByReceiptId": true, + "scopeKeysRequired": ["projectId", "agentId"], + "decider": "owner_or_admin_human", + "mubotSelfWiden": false, + "note": "Same three receipts cannot climb the ladder — consumeReceiptIds must be persisted; top of ladder (execute) is NOT auto-gated per autonomyImpliesGate" + }, + "progress": { + "allowedKinds": ["measured", "unmeasured", "unavailable"], + "forbidFakeGreen": true, + "v1Mode": "unmeasured_until_project_kpi", + "metricSourceIdType": "KpiSourceId", + "sourceWiredCallerBoolean": false + }, + "unmetDependencies": [ + { + "id": "tier1-persistent-mubot-505", + "status": "closed-unmerged", + "blocks": ["talk-facet"], + "decision": "talk-optional-on-owner-home-until-revived" + }, + { + "id": "tier2-user-chat", + "status": "dyad-gate-blocked", + "blocks": ["talk-facet-every-member"] + }, + { + "id": "brainport-default-adapter", + "status": "type-only-sealed", + "blocks": ["rank-via-BrainPort"], + "interim": "rankTasks" + }, + { + "id": "project-scoped-kpi-source", + "status": "not-built", + "blocks": ["measured-progress"], + "v1": "unmeasured_until_project_kpi" + }, + { + "id": "model-port-v2-tool-calling", + "status": "not-built", + "blocks": ["tier2-tool-loops"] + }, + { + "id": "pot-monthly-model-ledger", + "status": "display-only", + "blocks": ["honest-talk-budget-story"] + }, + { + "id": "per-project-docs-ui", + "status": "design-only", + "blocks": ["know-facet-full-editor"] + }, + { + "id": "port-4-instinct-memory", + "status": "dormant-branch", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "learning-ranker", + "status": "under-dyad-gate", + "blocks": ["learn-adapt-instinct"] + }, + { + "id": "structured-project-outcome", + "status": "not-on-projects-table", + "blocks": ["outcome-strip"] + } + ], + "nonGoals": [ + "route-in-this-design-commit", + "migration-applied-in-this-design-commit", + "ui-in-this-design-commit", + "merge-port4-or-ranker-from-this-worktree", + "pretend-modelport-v2-exists", + "cite-dormant-surfaces-as-reuse", + "cite-BrainPort-as-reuse" + ], + "buildSlices": [ + "outcome-model", + "owner-home-shell", + "trust-strip-gate-queue", + "earned-autonomy", + "visible-learning", + "talk-facet-tier2-after-pass", + "know-facet-docs-507", + "learn-adapt-instinct-after-port4" + ], + "suggestedBuildOrder": [ + "trust-strip-gate-queue", + "visible-learning", + "outcome-model", + "earned-autonomy", + "owner-home-shell", + "know-facet-docs-507", + "talk-facet-tier2-after-pass", + "learn-adapt-instinct-after-port4" + ] +}