From 02ad5a2dcdc4d3493a230b32190f0574fabd8f49 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 11:48:42 +0800 Subject: [PATCH 01/25] docs: add project context v4 implementation plans --- .../2026-09-04-decisions-and-candidates.md | 286 +++++++ .../2026-09-04-modelpricewatch-pricing.md | 344 +++++++++ .../2026-09-04-obsidian-all-sessions-view.md | 321 ++++++++ ...09-04-obsidian-context-gate-0-contracts.md | 346 +++++++++ ...6-09-04-session-index-publication-query.md | 306 ++++++++ ...idian-project-context-navigation-design.md | 722 ++++++++++++++++++ 6 files changed, 2325 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-04-decisions-and-candidates.md create mode 100644 docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md create mode 100644 docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md create mode 100644 docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md create mode 100644 docs/superpowers/plans/2026-09-04-session-index-publication-query.md create mode 100644 docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md diff --git a/docs/superpowers/plans/2026-09-04-decisions-and-candidates.md b/docs/superpowers/plans/2026-09-04-decisions-and-candidates.md new file mode 100644 index 0000000..fc6de89 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-decisions-and-candidates.md @@ -0,0 +1,286 @@ +# Decisions and Candidates Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the empty “关键决策” panel with a human-owned “决策与约定” workflow for creating, editing, superseding, and confirming evidence-bound AI candidates without allowing scans or Agents to invent accepted project semantics. + +**Architecture:** Confirmed decisions live in review-presentation-v4 and flow through existing semantic patches plus the three-file human publication transaction, which verifies but does not rewrite the current Session index. AI extraction writes immutable dependency-bound candidate revisions to a private annotation store under CAS. Only an explicit confirm transition creates a HumanPresentation decision revision. + +**Tech Stack:** Go 1.26, existing presentation patch/sync/publication/reviewjob infrastructure, private atomic JSON store, TypeScript 5.8, Obsidian 1.13, Vitest. + +**Spec:** `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` + +## Global Constraints + +- Prerequisites: Gate 0, Session index/query, and Obsidian four-tab shell are complete. +- Formal decisions originate only from `human_created`, `migrated`, or `ai_candidate_confirmed` provenance. +- Extraction failure, cancellation, invalid output, or candidate CAS conflict never changes the scan generation or extraction watermark. +- Candidates cite `(provider, session_id, session_view_digest, revision_id)` dependencies and contain no unverifiable confidence score. +- `confirmed`, `not_decision`, and `stale` candidate revisions are terminal; ignored may restore to pending. Stale dependencies cannot be confirmed. +- Decision supersession is acyclic. No physical delete is exposed; archive/supersede creates a new revision. +- Write commands read at most 64 KiB versioned JSON from stdin, validate review SHA and expected revision, and accept no caller-supplied file path. +- Human publication verifies `session-index.json` generation/digest but keeps its bytes unchanged. + +## File Structure and Ownership + +- `internal/reviewv4/decision.go`: decision validation, ordering, supersession graph. +- `internal/annotation/store.go`: private candidate/extraction-run CAS records. +- `internal/decision/service.go`: create/edit/transition orchestration. +- `internal/decision/extract.go`: deterministic dependency-set identity and proposal-only job lifecycle. +- `internal/presentation/`: decision fields and semantic patch rendering. +- `internal/publication/`: three-file human transaction plus read-only index guard. +- `internal/cli/decisions.go`: fixed write/read commands. +- `obsidian-plugin/src/view/render-decisions.ts`: confirmed and candidate sections. + +--- + +### Task 1: Persist v4 decision fields and safe supersession relationships + +**Files:** +- Create: `internal/reviewv4/decision.go`, `decision_test.go` +- Modify: `internal/presentation/project.go`, `patch.go`, related tests +- Modify: `internal/reviewv2/review_markdown.go` or Gate-0 compatibility adapter +- Modify: `obsidian-plugin/src/data/markdown.ts`, `tests/markdown.test.ts` + +**Interfaces:** + +```go +type Decision struct { + ID, Kind, OccurredAt, Title, Rationale, Impact, Status, ReevaluateWhen, Provenance string + Supersedes, MilestoneIDs []string + SessionRefs []SessionKey + Pinned bool + Revision int +} +func ValidateDecisionSet([]Decision) error +func OrderCurrent([]Decision) []Decision // pinned first, then occurred_at desc, stable ID +``` + +- [ ] **Step 1: Write RED round-trip tests** for every field, empty migrated defaults, same native Session ID across providers, pinned ordering, and revision preservation. + +```go +func TestDecisionRoundTripPreservesProviderQualifiedSessionRefs(t *testing.T) { + decision := minimumDecision() + decision.SessionRefs = []SessionKey{{Provider: "codex", SessionID: "same"}, {Provider: "claude", SessionID: "same"}} + got := roundTripDecision(t, decision) + if diff := cmp.Diff(decision.SessionRefs, got.SessionRefs); diff != "" { t.Fatal(diff) } +} +``` +- [ ] **Step 2: Add RED graph tests** for self-cycle, multi-node cycle, missing superseded predecessor, `status=superseded` without a successor, and archived chains. +- [ ] **Step 3: Run RED:** `go test ./internal/reviewv4 ./internal/presentation ./internal/reviewv2 -run Decision -count=1 && (cd obsidian-plugin && npm test -- markdown.test.ts)`. +- [ ] **Step 4: Implement validation and semantic field patches** for kind, reevaluation, status, relations, Session refs, pinned, and revision. Preserve unknown user Markdown outside controlled marker fields. + +```go +func ValidateDecisionSet(values []Decision) error { + byID := indexDecisions(values) + if err := validateSupersessionTargets(byID); err != nil { return err } + if cycle := findSupersessionCycle(byID); len(cycle) != 0 { return fmt.Errorf("decision supersession cycle: %s", strings.Join(cycle, " -> ")) } + return validateSupersededHasSuccessor(byID) +} +``` +- [ ] **Step 5: Run all gates and commit when authorized** with message `feat: persist human-owned decisions and agreements`. + +--- + +### Task 2: Implement private AgentAnnotation CAS storage + +**Files:** +- Create: `internal/annotation/store.go`, `store_test.go` +- Create: `internal/annotation/paths.go`, `paths_test.go` +- Modify: `internal/atomicfile/` only if a missing reusable lock primitive is proven + +**Interfaces:** + +```go +type Store interface { + Load(projectID string) (annotation.ProjectState, error) + CompareAndSwap(projectID string, expectedRevision int, next annotation.ProjectState) error +} +type ProjectState struct { + SchemaVersion, Revision int + Candidates []CandidateRevision + Runs []ExtractionRun + LastSuccessfulExtractionDependencies []string +} +``` + +- [ ] **Step 1: Write RED tests** for private permissions, project ID path confinement, atomic replace, concurrent revision conflict, duplicate candidate revision, terminal-state mutation rejection, stale marking, and crash recovery. + +```go +func TestStoreCompareAndSwapRejectsStaleRevision(t *testing.T) { + store := openTestStore(t) + current := minimumProjectState(2) + saveState(t, store, current) + err := store.CompareAndSwap("project-p", 1, current) + if codeOf(err) != "candidate_revision_conflict" { t.Fatalf("err=%v", err) } +} +``` +- [ ] **Step 2: Run RED:** `go test ./internal/annotation -run Store -count=1`. +- [ ] **Step 3: Implement one locked private project record** below the platform data root. Use safe IDs, no symlink traversal, canonical JSON, fsync/atomic replace, and typed `candidate_revision_conflict`. + +```go +func (s *FileStore) CompareAndSwap(projectID string, expected int, next ProjectState) error { + return s.withProjectLock(projectID, func(path string) error { + current, err := s.loadLocked(path) + if err != nil { return err } + if current.Revision != expected { return revisionConflict(current) } + next.Revision = expected + 1 + return atomicfile.WritePrivate(path, canonicalJSON(next)) + }) +} +``` +- [ ] **Step 4: Run all Go gates and commit when authorized** with message `feat: persist decision candidates under CAS`. + +--- + +### Task 3: Create and revise formal decisions through guarded human publication + +**Files:** +- Create: `internal/decision/service.go`, `service_test.go` +- Modify: `internal/publication/service.go`, `service_test.go`, `recovery_test.go` +- Modify: `internal/presentation/render.go`, `render_test.go` +- Modify: `internal/contextupdate/service.go` + +**Interfaces:** + +```go +type CreateRequest struct { ProjectID, ExpectedReviewSHA256 string; Input DecisionInput } +type TransitionRequest struct { ProjectID, CandidateID, Action, ExpectedReviewSHA256 string; ExpectedRevision int; Input *DecisionInput } +func (s *Service) Create(context.Context, CreateRequest) (DecisionResult, error) +func (s *Service) TransitionCandidate(context.Context, TransitionRequest) (CandidateResult, error) +``` + +- [ ] **Step 1: Write RED tests** for human create, candidate confirm with edits, ignore/restore/not-decision, stale candidate rejection, review-preimage conflict, candidate-revision conflict, and invalid supersession. + +```go +func TestConfirmCandidateRequiresCurrentReviewPreimage(t *testing.T) { + service := decisionServiceWithReviewSHA("new-sha") + _, err := service.TransitionCandidate(context.Background(), TransitionRequest{ProjectID: "project-p", CandidateID: "candidate-1", Action: "confirm", ExpectedRevision: 3, ExpectedReviewSHA256: "old-sha", Input: ptr(validDecisionInput())}) + if codeOf(err) != "review_preimage_conflict" { t.Fatalf("err=%v", err) } +} +``` +- [ ] **Step 2: Add transaction RED tests.** Confirming a candidate updates two Markdown files and ledger v4 atomically, verifies the bound index generation/hash, leaves index bytes unchanged, and rolls back all human files on failure. +- [ ] **Step 3: Run RED:** `go test ./internal/decision ./internal/publication ./internal/presentation -run 'Decision|Candidate|HumanPublication' -count=1`. +- [ ] **Step 4: Implement service ordering:** load accepted v4 + index guard; validate CAS/preimage; create semantic patch/new revision; render; publish three files; only then finalize candidate state. If final annotation CAS fails after publication, reconcile idempotently by accepted decision ID rather than publishing twice. + +```go +accepted, index := s.loadAcceptedWithIndexGuard(request.ProjectID) +if accepted.ReviewSHA256 != request.ExpectedReviewSHA256 { return CandidateResult{}, reviewConflict(accepted) } +decision := decisionFromCandidate(candidate, request.Input) +published, err := s.publishHumanRevision(ctx, accepted, index, decision) +if err != nil { return CandidateResult{}, err } +return s.finalizeConfirmedCandidate(candidate, published.DecisionID) +``` +- [ ] **Step 5: Run all Go gates and commit when authorized** with message `feat: confirm decisions through guarded publication`. + +--- + +### Task 4: Run idempotent dependency-bound candidate extraction + +**Files:** +- Create: `internal/decision/extract.go`, `extract_test.go` +- Modify: `internal/reviewjob/types.go`, `service.go`, corresponding tests +- Create: `schemas/decision-candidate-proposal-v1.schema.json` + +**Interfaces:** + +```go +func ExtractionIdentity(projectID, extractorVersion, promptSchemaVersion string, sortedNewSessionViewDigests []string) string +func (s *Service) StartExtraction(context.Context, ExtractRequest) (JobSummary, error) +func (s *Service) ExtractionStatus(context.Context, string) (JobSummary, error) +func (s *Service) CancelExtraction(context.Context, string, int) (JobSummary, error) +``` + +- [ ] **Step 1: Write RED identity tests** proving digest-order independence, changed dependency set creates a new run, and repeated clicks return the same running/completed job. + +```go +func TestExtractionIdentityIgnoresDependencyInputOrder(t *testing.T) { + left := ExtractionIdentity("project-p", "extractor-v1", "prompt-v1", []string{"sha256:b", "sha256:a"}) + right := ExtractionIdentity("project-p", "extractor-v1", "prompt-v1", []string{"sha256:a", "sha256:b"}) + if left != right { t.Fatalf("left=%s right=%s", left, right) } +} +``` +- [ ] **Step 2: Write RED proposal tests.** Reject missing Session/revision evidence, stale SessionView digest, unbounded text, unknown fields, confidence numbers, attempted edits to phase/next/risk, and duplicate semantic candidates within a run. +- [ ] **Step 3: Add watermark tests.** Only a successfully persisted candidate set advances `last_successful_extraction_dependencies`; empty valid success may advance; cancellation, Agent failure, malformed output, or store failure does not. +- [ ] **Step 4: Run RED:** `go test ./internal/decision ./internal/reviewjob -run Extraction -count=1`. +- [ ] **Step 5: Reuse the configured verified proposal-only Agent handle** and existing bounded job lifecycle. The prompt receives deterministic Session summaries/evidence refs, not raw transcripts or Vault write tools. + +```go +identity := ExtractionIdentity(request.ProjectID, extractorVersion, promptSchemaVersion, newDigests) +if existing, ok := state.RunByIdentity(identity); ok { return existing.Summary(), nil } +job := reviewjob.NewBoundedProposalJob(identity, buildCandidatePacket(summaries)) +return s.jobs.Start(ctx, job) +``` +- [ ] **Step 6: Run all Go gates and commit when authorized** with message `feat: extract evidence-bound decision candidates`. + +--- + +### Task 5: Expose decision CLI contracts + +**Files:** +- Create: `internal/cli/decisions.go`, `decisions_test.go` +- Modify: `internal/cli/run.go`, `run_test.go`, `contracts.go` + +- [ ] **Step 1: Write RED exact-argv/stdin tests** for create, extract, extract status/cancel, candidate list, and candidate transition. Cover 64 KiB+1 stdin, malformed JSON, absent expected SHA/revision, unknown action/status, extra file flags, and typed conflicts. + +```go +func TestDecisionCreateRejectsOversizedStdin(t *testing.T) { + input := bytes.NewReader(bytes.Repeat([]byte("x"), (64<<10)+1)) + code := runDecisions([]string{"create", "--project-id", "project-p", "--expected-review-sha256", validSHA, "--json"}, input, io.Discard, io.Discard) + if code != 2 { t.Fatalf("code=%d", code) } +} +``` +- [ ] **Step 2: Run RED:** `go test ./internal/cli -run Decisions -count=1`. +- [ ] **Step 3: Add `decisions` root dispatch** through Gate-0 grammar and `decision.Service`; return versioned JSON only, never human prose mixed into stdout. + +```go +case "decisions": + return runDecisions(args[1:], os.Stdin, stdout, stderr, decisionDependencies()) +``` +- [ ] **Step 4: Run all Go gates and commit when authorized** with message `feat: expose decision lifecycle CLI`. + +--- + +### Task 6: Render confirmed decisions, empty guidance, and candidates in Obsidian + +**Files:** +- Modify: `obsidian-plugin/src/contracts/review-v4.ts` +- Modify: `obsidian-plugin/src/cli/runner.ts`, `tests/cli.test.ts` +- Modify: `obsidian-plugin/src/view/render-decisions.ts` +- Modify: `obsidian-plugin/src/styles.css` +- Create: `obsidian-plugin/tests/decisions-v4-view.test.ts` + +- [ ] **Step 1: Write RED view tests** for no-decision explanatory copy, exactly two actions, active-only default, archived/superseded filters, top-three homepage order, provenance, reevaluation text, Session/milestone links, and supersession chain. + +```ts +it("explains an empty decision set and exposes two explicit actions", () => { + const panel = renderDecisions(modelWithoutDecisions(), noopUpdate, handlers()); + expect(panel.textContent).toContain("扫描已经保存项目事实,但不会替你判断项目意图"); + expect(panel.querySelectorAll("[data-decision-empty-action]")).toHaveLength(2); +}); +``` +- [ ] **Step 2: Add RED candidate interactions** for start/status/cancel, pending cards with evidence, edit-confirm, ignore, not-decision, restore, stale disabled confirmation, CAS refresh, and zero confidence display. +- [ ] **Step 3: Run RED:** `cd obsidian-plugin && npm test -- decisions-v4-view.test.ts cli.test.ts`. +- [ ] **Step 4: Implement full CLI methods and UI states** using fixed argv and bounded stdin. On a write success reload the four-file repository; on typed conflict show current summary and preserve unsaved form text. + +```ts +async function confirmCandidate(candidate: CandidateRevision, input: DecisionInput): Promise { + await cli.transitionCandidate(model.review.projectId, candidate.id, candidate.revision, "confirm", model.source.reviewSha256, input); + await reloadProject(); +} +``` +- [ ] **Step 5: Run `npm run check` and commit when authorized** with message `feat: add decisions and agreements workflow`. + +--- + +### Task 7: Real acceptance and semantic-boundary audit + +**Files:** +- Create: `docs/session-review/decisions-acceptance.md` + +- [ ] **Step 1: In a disposable real Vault with no decisions,** confirm the explanation and both actions render instead of a blank panel. +- [ ] **Step 2: Create one decision and one agreement manually,** edit all fields, pin one, supersede one, archive one, reload Obsidian, and verify Markdown remains human-readable/editable. +- [ ] **Step 3: Extract candidates from newly indexed Sessions,** inspect cited facts, edit-confirm one, ignore/restore one, mark one not-decision, then rescan and verify stale dependency behavior. +- [ ] **Step 4: Inject Agent failure, cancellation, malformed proposal, review edit conflict, and annotation CAS conflict.** Verify no scan generation or watermark incorrectly advances and no mixed publication appears. +- [ ] **Step 5: Audit accepted decisions:** each must have one allowed provenance and an explicit human action in the evidence trail; deterministic scan output alone must never create one. +- [ ] **Step 6: Record installed bundle hashes, generation/revisions, commands, screenshots, and results in the acceptance document; commit when authorized.** diff --git a/docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md b/docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md new file mode 100644 index 0000000..823deb2 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md @@ -0,0 +1,344 @@ +# ModelPriceWatch Pricing Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Resolve Session usage against auditable, immutable price snapshots from ModelPriceWatch, official sources, or manual supplements while keeping unknown and conditional prices visibly incomplete. + +**Architecture:** A global private cache refreshes the ModelPriceWatch models and history catalogs at most once per 24 hours. A reviewed alias table maps actual billing route tuples to one listing ID; no fuzzy model/provider match is allowed. Provider-specific UsageAdapters produce mutually exclusive billable quantities. Accepted pricing creates immutable `pricing-snapshot-v1` records in machine-ledger-v4, and Obsidian renders known subtotal, completeness, age, promotion, and full source links. + +**Tech Stack:** Go 1.26 `net/http`, existing pathguard/atomicfile locks, strict JSON, v4 ledger/publication, TypeScript 5.8, Obsidian 1.13, Vitest. + +**Spec:** `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` + +## Global Constraints + +- Prerequisites: Gate 0 and Session index publication are complete. Obsidian usage work can follow after the four-tab shell exists. +- Verified live API shapes on 2026-09-04: `models.json` is `{count, updated, data: Listing[]}` and includes `id`, `provider`, `model`, nullable price fields, `promo`, `promo_until`, `price_note`, `pricing_url`, `last_updated`, and `detail_url`; `price-history.json` is `{count, updated, data: {listingID: {model, provider, history[]}}}`. Treat this as an adapter version, not a perpetual guarantee. +- Endpoints are fixed HTTPS URLs: `https://modelpricewatch.com/api/v1/models.json` and `https://modelpricewatch.com/api/v1/price-history.json`. Redirects may remain only on the same origin and must end at HTTPS. +- Each response has a 128 MiB download and parse ceiling, must have successful status, JSON content type, supported schema, unique fields, and complete EOF. +- Cache age uses local `retrieved_at`: current <=24h; stale estimate >24h and <=7d; older than 7d cannot create a newly priced snapshot. +- `price_note` is evidence text only. Any tier, region, batch, cache, modality, promotion, or other condition not represented structurally makes automatic application pending/ambiguous. +- A public zero rate is numeric `0`; unknown is `null`. Missing price never becomes zero cost and never blocks scanning. +- Listing directory/cache refresh does not rewrite existing snapshots. Corrections create a superseding snapshot. +- The UI must link the full ModelPriceWatch detail URL and official pricing URL when available and state that estimates are not invoices. + +## File Structure and Ownership + +- `internal/modelpricewatch/client.go`: fixed-origin HTTP refresh and strict adapters. +- `internal/modelpricewatch/cache.go`: global private lock, metadata, atomic files. +- `internal/pricing/aliases.go`: reviewed billing-route-to-listing mapping. +- `internal/pricing/usage_adapter.go`: provider-specific mutually exclusive billable quantities. +- `internal/pricing/resolve.go`: temporal applicability and snapshot creation. +- `internal/reviewv4/`: snapshot chains and nullable accounting aggregates. +- `obsidian-plugin/src/view/render-usage.ts`: one full-width card per model with status and source links. + +--- + +### Task 1: Implement strict ModelPriceWatch response adapters + +**Files:** +- Create: `internal/modelpricewatch/types.go`, `decode.go`, `decode_test.go` +- Create: `internal/modelpricewatch/testdata/models-min.json`, `history-min.json` + +**Interfaces:** + +```go +type Catalog struct { Count int; Updated string; Listings map[string]Listing } +type HistoryCatalog struct { Count int; Updated string; Models map[string]ModelHistory } +func DecodeModels(io.Reader, int64) (Catalog, error) +func DecodeHistory(io.Reader, int64) (HistoryCatalog, error) +``` + +- [ ] **Step 1: Capture trimmed synthetic fixtures matching the verified live shape.** Do not commit the full remote catalogs. + +```json +{"count":1,"updated":"2026-09-03","data":[{"id":"openai-gpt-test","provider":"OpenAI","model":"GPT Test","input_per_mtok":1.0,"output_per_mtok":2.0,"cached_input_per_mtok":null,"promo":false,"promo_until":null,"price_note":null,"pricing_url":"https://example.com/pricing","last_updated":"2026-09-03","detail_url":"https://modelpricewatch.com/models/openai-gpt-test/"}]} +``` +- [ ] **Step 2: Write RED tests** for nullable cache rate, explicit zero, duplicate listing ID, count mismatch, duplicate JSON field, unknown top-level field, invalid date/URL, malformed/truncated JSON, nonfinite/negative price, and 128 MiB+1 input. + +```go +func TestDecodeModelsPreservesUnknownAndFreeRates(t *testing.T) { + catalog, err := DecodeModels(strings.NewReader(modelsFixture(nil, ptr(0.0))), 128<<20) + if err != nil { t.Fatal(err) } + row := catalog.Listings["openai-gpt-test"] + if row.CachedInputPerMTok != nil || row.OutputPerMTok == nil || *row.OutputPerMTok != 0 { t.Fatalf("row=%+v", row) } +} +``` +- [ ] **Step 3: Add a RED conditional-note test:** a listing with `price_note` containing tier/region/promo terms is decoded but marked `HasUnstructuredConditions=true`, never directly “current”. +- [ ] **Step 4: Run RED:** `go test ./internal/modelpricewatch -run Decode -count=1`. +- [ ] **Step 5: Implement strict streaming decode** with exact allowed fields, count/ID/history-key consistency, and an adapter schema-version constant. Preserve nullable rates and source URLs/dates. + +```go +func DecodeModels(reader io.Reader, limit int64) (Catalog, error) { + body, err := readCompleteBounded(reader, limit) + if err != nil { return Catalog{}, err } + if err := rejectDuplicateJSONKeys(body); err != nil { return Catalog{}, err } + var wire modelsWire + if err := decodeStrict(body, &wire); err != nil { return Catalog{}, err } + return validateModelsWire(wire) +} +``` +- [ ] **Step 6: Run all Go gates and commit when authorized** with message `feat: decode ModelPriceWatch catalogs strictly`. + +--- + +### Task 2: Refresh a global private cache safely and infrequently + +**Files:** +- Create: `internal/modelpricewatch/client.go`, `client_test.go` +- Create: `internal/modelpricewatch/cache.go`, `cache_test.go` +- Modify: `internal/platform/paths.go`, `paths_test.go` + +**Interfaces:** + +```go +type HTTPDoer interface { Do(*http.Request) (*http.Response, error) } +type Cache struct { /* private root and lock */ } +func (c *Cache) LoadOrRefresh(ctx context.Context, now time.Time) (CatalogSet, Freshness, error) +``` + +- [ ] **Step 1: Write RED HTTP tests** for fixed URLs, user agent, ETag/If-Modified-Since, 304 reuse, same-origin HTTPS redirects, cross-origin/downgrade redirect refusal, status/content-type/length failures, timeout, and partial body. + +```go +func TestClientRejectsCrossOriginRedirect(t *testing.T) { + server := redirectServer(t, "https://evil.example/models.json") + client := NewClient(server.HTTPClient(), server.ModelsURL(), server.HistoryURL()) + _, err := client.Fetch(context.Background(), Validators{}) + if codeOf(err) != "unsafe_redirect" { t.Fatalf("err=%v", err) } +} +``` +- [ ] **Step 2: Write RED cache tests** proving two projects/processes trigger at most one refresh per 24h, 24h+1 may refresh, failed refresh keeps old bytes and retrieved time, and files/lock have private permissions on macOS/Windows. +- [ ] **Step 3: Run RED:** `go test ./internal/modelpricewatch ./internal/platform -run 'Client|Cache|Refresh' -count=1`. +- [ ] **Step 4: Implement separate atomically replaced catalog files plus metadata.** Validate both new responses before replacing either active pair; do not expose half-updated models/history. + +```go +return c.withGlobalLock(func() (CatalogSet, Freshness, error) { + current := c.loadValidatedPair() + if now.Sub(current.RetrievedAt) <= 24*time.Hour { return current.Catalogs, FreshCurrent, nil } + next, validators, err := c.client.Fetch(ctx, current.Validators) + if err != nil { return staleOrError(current, now, err) } + return c.replaceValidatedPairAtomically(next, validators, now) +}) +``` +- [ ] **Step 5: Run all Go gates and commit when authorized** with message `feat: cache ModelPriceWatch catalogs safely`. + +--- + +### Task 3: Match exact billing routes and reject ambiguous conditions + +**Files:** +- Create: `internal/pricing/aliases.go`, `aliases_test.go` +- Create: `config/modelpricewatch-aliases.json` +- Modify: `internal/config/config.go`, `config_test.go` + +**Interfaces:** + +```go +type BillingRoute struct { Host, ModelID, Mode string; Region *string } +type Alias struct { Route BillingRoute; ListingID string } +type Match struct { Status PriceStatus; ListingID *string; Reason string } +func MatchListing(route BillingRoute, aliases []Alias, catalog modelpricewatch.Catalog, at time.Time) Match +``` + +- [ ] **Step 1: Write RED tests** for exact tuple match, case/whitespace mismatch, same model at two hosts, duplicate tuple to different listings, missing listing, retired listing, unknown region/mode, structured promotion, and unstructured `price_note`. + +```go +func TestMatchListingDoesNotFuzzyMatchModelName(t *testing.T) { + aliases := []Alias{{Route: BillingRoute{Host: "api.openai.com", ModelID: "gpt-exact", Mode: "api"}, ListingID: "openai-gpt"}} + got := MatchListing(BillingRoute{Host: "api.openai.com", ModelID: "GPT-EXACT", Mode: "api"}, aliases, catalog(), fixedTime) + if got.Status != pricing.Pending || got.ListingID != nil { t.Fatalf("got=%+v", got) } +} +``` +- [ ] **Step 2: Run RED:** `go test ./internal/pricing ./internal/config -run 'Alias|MatchListing' -count=1`. +- [ ] **Step 3: Implement reviewed aliases.** Validate uniqueness at startup; do not derive aliases by lowercasing, substring, provider name, or model display name. Return `pending` for no exact mapping and `ambiguous` for conflicts/conditions. + +```go +key := routeKey{Host: route.Host, ModelID: route.ModelID, Mode: route.Mode, Region: route.Region} +listingID, ok := exactAliases[key] +if !ok { return Match{Status: Pending, Reason: "no_exact_billing_route_alias"} } +listing := catalog.Listings[listingID] +if listing.HasUnstructuredConditions { return Match{Status: Ambiguous, ListingID: &listingID, Reason: "unstructured_price_conditions"} } +``` +- [ ] **Step 4: Seed only aliases proven by real billing metadata and catalog listing IDs.** An empty alias table is valid and yields pending snapshots. +- [ ] **Step 5: Run all Go gates and commit when authorized** with message `feat: match exact model billing routes`. + +--- + +### Task 4: Produce provider-specific billable quantities + +**Files:** +- Create: `internal/pricing/usage_adapter.go`, `usage_adapter_test.go` +- Create: `internal/pricing/codex_usage.go`, `claude_usage.go`, `opencode_usage.go` +- Modify: `internal/accounting/accounting.go`, `accounting_test.go` + +**Interfaces:** + +```go +type BillableQuantities struct { Input, CachedInput, CacheWriteInput, Output, ReasoningOutput int64; RuleVersion string } +type UsageAdapter interface { + Provider() string + Billable(accounting.ModelUsage) (BillableQuantities, []string, error) +} +``` + +- [ ] **Step 1: Write RED provider fixtures** covering overlapping total/input/cache fields, Claude cache-read/cache-creation, OpenCode providerID/modelID, reasoning included in output vs separately billed, missing model, and totals mismatch. + +```go +func TestClaudeBillableKeepsCacheReadAndCreationExclusive(t *testing.T) { + got, missing, err := ClaudeUsageAdapter{}.Billable(usageWith(100, 20, 5, 30, 0)) + if err != nil || len(missing) != 0 { t.Fatalf("missing=%v err=%v", missing, err) } + if got.Input != 75 || got.CachedInput != 20 || got.CacheWriteInput != 5 || got.Output != 30 { t.Fatalf("got=%+v", got) } +} +``` +- [ ] **Step 2: Assert quantities are nonnegative and mutually exclusive.** The sum/relationship rule is provider-version-specific; no generic subtraction may create negative or double-counted quantities. +- [ ] **Step 3: Run RED:** `go test ./internal/pricing ./internal/accounting -run 'Billable|UsageAdapter' -count=1`. +- [ ] **Step 4: Implement explicit versioned rules** and persist route metadata plus rule version with each usage association. Unsupported shapes produce missing dimensions, not guessed cost. + +```go +return BillableQuantities{Input: usage.InputTokens-usage.CachedInputTokens-usage.CacheWriteInputTokens, CachedInput: usage.CachedInputTokens, CacheWriteInput: usage.CacheWriteInputTokens, Output: usage.OutputTokens, RuleVersion: "claude-usage-v1"}, nil, nil +``` +- [ ] **Step 5: Run all Go gates and commit when authorized** with message `feat: derive provider billable quantities`. + +--- + +### Task 5: Create immutable pricing snapshots and nullable aggregates + +**Files:** +- Create: `internal/pricing/resolve.go`, `resolve_test.go`, `aggregate.go`, `aggregate_test.go` +- Modify: `internal/reviewv4/types.go`, `codec.go`, tests +- Modify: `internal/presentation/render.go`, tests +- Modify: `internal/contextupdate/service.go` + +**Interfaces:** + +```go +type ResolveInput struct { ProjectID string; Session SessionKey; UsageDigest string; Route BillingRoute; Usage accounting.ModelUsage; PricedAt time.Time; Catalog CatalogSet; Prior *Snapshot } +func Resolve(ResolveInput) (pricing.Snapshot, error) +func Aggregate(usages []UsageAssociation, snapshots []Snapshot) accounting.ProjectSummary +``` + +- [ ] **Step 1: Write RED temporal tests** for catalog <=24h current, 24h+1 to 7d stale estimate with age, >7d pending, historical price selection by `priced_at`, promotion status/end, and future-only price history. + +```go +func TestResolveUsesStaleEstimateOnlyThroughSevenDays(t *testing.T) { + current := resolveAtAge(t, 24*time.Hour+time.Second) + if current.Status != StaleEstimate { t.Fatalf("status=%s", current.Status) } + expired := resolveAtAge(t, 7*24*time.Hour+time.Second) + if expired.Status != Pending || expired.TotalCostUSD != nil { t.Fatalf("snapshot=%+v", expired) } +} +``` +- [ ] **Step 2: Write RED snapshot tests** for nullable rates/line costs, exact decimal calculation, known subtotal, incomplete total null, complete total, explicit free price, missing dimensions, and deterministic snapshot ID. +- [ ] **Step 3: Add chain tests** for manual supplement/correction, `supersedes_snapshot_id`, cycle/branch rejection, immutable old snapshots, and aggregation selecting the newest valid chain head only. +- [ ] **Step 4: Run RED:** `go test ./internal/pricing ./internal/reviewv4 ./internal/presentation ./internal/contextupdate -run 'Resolve|Snapshot|Aggregate|Pricing' -count=1`. +- [ ] **Step 5: Implement resolution independent of scan success.** A catalog/network/match failure emits unresolved pricing state while Token usage and Session publication continue. Store accepted snapshots inside ledger v4 and validate the entire chain on load. + +```go +match := MatchListing(in.Route, in.Aliases, in.Catalog.Models, in.PricedAt) +if match.Status == Pending || match.Status == Ambiguous { + return unresolvedSnapshot(in, match.Status, match.Reason), nil +} +return pricedSnapshot(in, selectApplicableHistory(in.Catalog.History, *match.ListingID, in.PricedAt)) +``` +- [ ] **Step 6: Run all Go gates and commit when authorized** with message `feat: persist auditable pricing snapshots`. + +--- + +### Task 6: Expose guarded manual supplement CLI + +**Files:** +- Create: `internal/cli/pricing.go`, `pricing_test.go` +- Modify: `internal/cli/run.go`, `run_test.go`, `contracts.go` +- Modify: `internal/pricing/resolve.go`, `resolve_test.go` + +**Interfaces:** + +```go +type SupplementInput struct { + SchemaVersion int + Route BillingRoute + PricedAt string + Rates NullableRates + SourceURL string + AuditReason string + SupersedesSnapshotID *string +} +func (s *Service) Supplement(context.Context, SupplementRequest) (Snapshot, error) +``` + +- [ ] **Step 1: Write RED CLI tests** for exact project/provider/session/usage-digest/ledger-SHA argv, 64 KiB stdin, invalid source URL, missing audit reason, stale ledger preimage, wrong usage digest, unknown dimensions, and attempted caller-provided line costs/totals. + +```go +func TestPricingSupplementRejectsCallerCalculatedCost(t *testing.T) { + body := `{"schema_version":1,"route":{"host":"api.example","model_id":"m","mode":"api","region":null},"priced_at":"2026-09-04T00:00:00Z","rates":{"input":1},"line_costs_usd":{"input":999},"source_url":"https://example.com/pricing","audit_reason":"manual invoice check"}` + code := runPricing([]string{"supplement", "--project-id", "project-p", "--provider", "codex", "--session-id", "s1", "--usage-record-digest", validDigest, "--expected-ledger-sha256", validSHA, "--json"}, strings.NewReader(body)) + if code != 2 { t.Fatalf("code=%d", code) } +} +``` + +- [ ] **Step 2: Run RED:** `go test ./internal/cli ./internal/pricing -run Supplement -count=1`. +- [ ] **Step 3: Implement strict stdin decode and root dispatch.** Reload ledger/usage under lock, verify preimages, derive billable quantities server-side, recompute line costs/subtotal/total, append a `manual_supplement` snapshot, and publish ledger through the guarded human transaction. + +```go +case "pricing": + return runPricing(args[1:], os.Stdin, stdout, stderr, pricingDependencies()) +``` + +- [ ] **Step 4: Run all Go gates and commit when authorized** with message `feat: add guarded manual pricing supplements`. + +--- + +### Task 7: Render honest full-width usage cards in Obsidian + +**Files:** +- Modify: `obsidian-plugin/src/contracts/review-v4.ts` +- Modify: `obsidian-plugin/src/data/contracts-v4.ts` +- Modify: `obsidian-plugin/src/cli/runner.ts`, `obsidian-plugin/tests/cli.test.ts` +- Modify: `obsidian-plugin/src/view/render-usage.ts` +- Modify: `obsidian-plugin/src/styles.css` +- Create: `obsidian-plugin/tests/pricing-view.test.ts` + +- [ ] **Step 1: Write RED card tests** for each price status, known subtotal vs total unavailable, explicit free, missing dimensions, cache age, promotion/end, manual supplement, superseded audit chain, and disclaimer. + +```ts +it("never renders an unknown total as zero", () => { + const panel = renderUsage(modelWithPricing({ knownSubtotalUsd: 1.25, totalCostUsd: null, pricingComplete: false })); + expect(panel.textContent).toContain("已知小计 $1.25"); + expect(panel.textContent).toContain("总费用暂不可用"); + expect(panel.textContent).not.toContain("总费用 $0"); +}); +``` +- [ ] **Step 2: Assert one full-width card per model** at desktop widths, with an incomplete last row allowed only if responsive layout later uses multiple columns. Full clickable `定价来源` URLs must not collapse into an unlabeled icon. +- [ ] **Step 3: Add URL safety tests.** Render only validated HTTPS ModelPriceWatch/official source URLs; unsafe/malformed URLs become non-clickable text with an invalid-source diagnostic. +- [ ] **Step 4: Run RED:** `cd obsidian-plugin && npm test -- pricing-view.test.ts styles.test.ts`. +- [ ] **Step 5: Implement status-localized cards** showing rate dimensions, quantities, line costs, known subtotal, total/completeness, `last_updated`, `retrieved_at` age, ModelPriceWatch detail attribution, official pricing link, and “估算并非账单”. + +```ts +const total = snapshot.pricingComplete && snapshot.totalCostUsd !== null + ? definition("总费用", formatUsd(snapshot.totalCostUsd)) + : definition("总费用", "暂不可用"); +card.append(definition("已知小计", formatUsd(snapshot.knownSubtotalUsd)), total, pricingSources(snapshot)); +``` + +Add a “人工补价/纠错” form that submits `pricing-supplement-v1` through a fixed `CliRunner.pricingSupplement` method; the form sends rates and audit input only, never calculated cost fields. + +```ts +await cli.pricingSupplement(model.review.projectId, identity, usageDigest, model.source.ledgerSha256, { + schemaVersion: 1, route, pricedAt, rates, sourceUrl, auditReason, supersedesSnapshotId +}); +``` +- [ ] **Step 6: Run `npm run check` and commit when authorized** with message `feat: present auditable model pricing`. + +--- + +### Task 8: Network, migration, and installed-bundle acceptance + +**Files:** +- Create: `docs/session-review/pricing-acceptance.md` +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Run deterministic fake-server integration tests** for first refresh, 304, concurrent projects, timeout, rate limit, malformed/oversized response, partial pair, and stale fallback. No CI test depends on live ModelPriceWatch availability. +- [ ] **Step 2: Run a read-only live adapter probe** against both fixed endpoints, recording HTTP status/content type, top-level shape, adapter version, catalog `updated`, count consistency, and no response body in logs. A shape change blocks release and requires an adapter/test update. +- [ ] **Step 3: Migrate v3 priced/unpriced accounting.** Existing price rows become `legacy_unverified`; unknown totals become null; no historical source/date/host is invented. +- [ ] **Step 4: In a disposable real Vault, verify** current, promotion, stale estimate, manual supplement, ambiguous, pending, legacy unverified, and superseded-chain cards; then disconnect network and confirm scan/index still update. +- [ ] **Step 5: Verify source links open the exact ModelPriceWatch detail and official pricing URLs, all dates/statuses are visible, and no unknown price displays `$0`. +- [ ] **Step 6: Run macOS/Windows Go and plugin gates** and record commands, cache ages, fixture routes, snapshot IDs, ledger/bundle hashes, screenshots, and live-probe metadata in the acceptance document; commit when authorized. diff --git a/docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md b/docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md new file mode 100644 index 0000000..c7a6695 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md @@ -0,0 +1,321 @@ +# Obsidian All Sessions View Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give Obsidian users a complete, filterable, virtualized Session list and bounded detail browser that remains honest when the CLI or source data is unavailable. + +**Architecture:** The repository loads the two Markdown files, v4 ledger, and `session-index-v1` as one generation-bound snapshot. Index-only filters run locally over the complete compact list. Summary, event pages, and semantic searches call only the fixed read-only CLI methods. UI state stores identities and ordinals rather than entire event arrays, so long Sessions remain bounded. + +**Tech Stack:** TypeScript 5.8, Obsidian 1.13, existing DOM render helpers, Vitest/jsdom, fixed-argv Node `execFile` wrapper. + +**Spec:** `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` + +## Global Constraints + +- Prerequisites: Gate 0 and `2026-09-04-session-index-publication-query.md` are complete. +- Tab order and labels are exactly `项目演进`, `决策与约定`, `全部 Sessions`, `用量`. +- The index list is complete. Virtualization changes DOM node count only; it never slices the data model or hides total/current-range counts. +- Without a verified CLI, date/provider/processing/source-availability filters and the full index still work. Only summary, deep events, and branch/file/error searches are disabled, with one recovery action. +- CLI calls use `execFile` with `shell:false`, absolute executable, fixed arrays, 10-second timeout, and bounded stdout/stderr. No user string becomes a path or executable. +- Stale cursor/generation responses refresh the snapshot and restore the closest valid ordinal in the same `(provider, session_id)`; never jump to another Session silently. +- All UI is keyboard accessible and respects Obsidian light/dark themes and reduced motion. + +## File Structure and Ownership + +- `obsidian-plugin/src/contracts/review-v4.ts`: browser model and query response types. +- `obsidian-plugin/src/data/repository.ts`: four-file snapshot loading, hash/generation validation, watchers. +- `obsidian-plugin/src/cli/runner.ts`: fixed inspect methods and strict response parsing. +- `obsidian-plugin/src/state/store.ts`: persisted filter/selection/page state, no event payload cache. +- `obsidian-plugin/src/view/render-shell.ts`: four-tab navigation. +- `obsidian-plugin/src/view/render-sessions.ts`: coverage, filters, list, detail, paging and recovery states. +- `obsidian-plugin/src/view/virtual-list.ts`: bounded DOM window over a complete array. + +--- + +### Task 1: Load and watch the generation-bound Session index + +**Files:** +- Modify: `obsidian-plugin/src/contracts/review-v4.ts` +- Modify: `obsidian-plugin/src/data/repository.ts` +- Create: `obsidian-plugin/tests/session-index-repository.test.ts` +- Modify: `obsidian-plugin/tests/repository.test.ts`, `discovery.test.ts` + +**Interfaces:** + +```ts +export interface BrowserSourceV4 extends BrowserSource { + sessionIndexPath: string; + sessionIndexSha256: string; + ledgerSha256: string; +} +export interface BrowserModelV4 extends BrowserModel { + generationId: string; + sessionIndex: SessionIndexV1; +} +``` + +- [ ] **Step 1: Write RED repository tests** for a valid four-file snapshot, missing index, malformed index, mixed project/generation/ProjectView digest, tampered digest, v3 migration-required state, and last-valid snapshot fallback. + +```ts +it("rejects a session index from another generation", async () => { + const vault = fourFileVault({ ledgerGeneration: "g2", indexGeneration: "g1" }); + const snapshot = await new ProjectRepository(vault).load(project("project-p")); + expect(snapshot.kind).toBe("empty"); + expect(snapshot.kind === "empty" && snapshot.diagnostic?.code).toBe("stale_snapshot"); +}); +``` +- [ ] **Step 2: Run RED:** `cd obsidian-plugin && npm test -- session-index-repository.test.ts repository.test.ts`; expect missing index support. +- [ ] **Step 3: Load `.session-reviewer/session-index.json`** with the Gate-0 strict parser before constructing `BrowserModelV4`. Add it to the watcher set and validate exact project/generation/ProjectView bindings against ledger v4. + +```ts +const sessionIndexPath = `${project.root}/.session-reviewer/session-index.json`; +const sessionIndex = parseSessionIndexV1(await this.vault.read(sessionIndexPath)); +assertSnapshotBindings(machine, sessionIndex); +``` +- [ ] **Step 4: Run GREEN:** `cd obsidian-plugin && npm run check`. +- [ ] **Step 5: Commit when authorized:** `git add obsidian-plugin/src/contracts obsidian-plugin/src/data obsidian-plugin/tests && git commit -m "feat: load complete session index in Obsidian"`. + +--- + +### Task 2: Add fixed-argv inspect methods and bounded parsers + +**Files:** +- Modify: `obsidian-plugin/src/cli/runner.ts` +- Modify: `obsidian-plugin/tests/cli.test.ts` + +**Interfaces:** + +```ts +sessionSummary(projectId: string, key: SessionIdentity, expectedGenerationId: string): Promise; +sessionEvents(projectId: string, key: SessionIdentity, expectedGenerationId: string, page: { cursor?: string; anchor?: number; limit: number }): Promise; +sessionSearch(projectId: string, expectedGenerationId: string, request: { kind: "branch"|"file"|"error"; query: string; cursor?: string; limit: number }): Promise; +``` + +- [ ] **Step 1: Add RED argv tests** that assert provider and Session ID are separate arguments, cursor/anchor are exclusive, limit is 1–100, query is at most 256 UTF-8 bytes, and no shell or path argument is accepted. + +```ts +it("uses separate provider and session identity arguments", async () => { + await runner.sessionSummary("project-p", { provider: "claude", sessionId: "same" }, "g1"); + expect(exec.args).toEqual(["inspect", "session-summary", "--project-id", "project-p", "--provider", "claude", "--session-id", "same", "--expected-generation-id", "g1", "--json"]); + expect(exec.options.shell).toBe(false); +}); +``` +- [ ] **Step 2: Add RED response tests** for malformed JSON, duplicate identity, wrong generation/session, oversized stdout, timeout, `stale_cursor`, and `anchor_out_of_range`. +- [ ] **Step 3: Run RED:** `cd obsidian-plugin && npm test -- cli.test.ts`. +- [ ] **Step 4: Implement methods through the existing private `run`/`runJSON` path** and Gate-0 parsers; raise typed `CliContractError` carrying code and current summary when present. + +```ts +async sessionSummary(projectId: string, key: SessionIdentity, generation: string): Promise { + validateIdentity(key); + validateProject(projectId); + return parseSessionSummaryV1(await this.runJSON(["inspect", "session-summary", "--project-id", projectId, "--provider", key.provider, "--session-id", key.sessionId, "--expected-generation-id", generation, "--json"])); +} +``` +- [ ] **Step 5: Run `npm run check` and commit when authorized** with message `feat: add safe session inspect client`. + +--- + +### Task 3: Model complete filters and persistent Session view state + +**Files:** +- Create: `obsidian-plugin/src/state/session-filter.ts`, `session-filter.test.ts` +- Modify: `obsidian-plugin/src/state/store.ts` +- Modify: `obsidian-plugin/src/view/render-shell.ts` +- Modify: `obsidian-plugin/tests/store.test.ts`, `view.test.ts` + +**Interfaces:** + +```ts +export interface SessionFilter { + providers: string[]; + processingStates: ProcessingState[]; + sourceAvailability: SourceAvailability[]; + startedFrom?: string; + startedTo?: string; +} +export interface ViewState { + view: "evolution"|"decisions"|"sessions"|"usage"; + selectedSession?: SessionIdentity; + sessionFilter: SessionFilter; + sessionOrdinal: number; + sessionEventOrdinal: number; +} +export function filterSessions(index: SessionIndexV1, filter: SessionFilter): SessionIndexEntry[]; +``` + +- [ ] **Step 1: Write RED tests** using 154 entries, duplicate native IDs across providers, null dates, and every processing/source state. Assert stable order and exact filtered totals. + +```ts +it("keeps duplicate native IDs from different providers", () => { + const rows = filterSessions(indexOf(entry("codex", "same"), entry("claude", "same")), emptyFilter()); + expect(rows.map((row) => `${row.provider}/${row.sessionId}`)).toEqual(["claude/same", "codex/same"]); +}); +``` +- [ ] **Step 2: Run RED:** `cd obsidian-plugin && npm test -- session-filter.test.ts store.test.ts view.test.ts`. +- [ ] **Step 3: Implement immutable filter normalization and persisted state migration.** Drop unavailable provider filters, retain selection only by full identity, and clamp ordinals only after the same filtered dataset is recomputed. + +```ts +export function sameSession(left?: SessionIdentity, right?: SessionIdentity): boolean { + return left !== undefined && right !== undefined && left.provider === right.provider && left.sessionId === right.sessionId; +} +``` +- [ ] **Step 4: Change shell tabs to the exact four-item order** with ArrowLeft/Right/Home/End keyboard behavior and `sessions` panel dispatch. +- [ ] **Step 5: Run `npm run check` and commit when authorized** with message `feat: model complete session navigation state`. + +--- + +### Task 4: Preserve readable Project Evolution progressive disclosure + +**Files:** +- Modify: `obsidian-plugin/src/view/render-evolution.ts` +- Modify: `obsidian-plugin/src/view/render-shell.ts` +- Modify: `obsidian-plugin/tests/large-history.test.ts`, `view.test.ts` +- Modify: `internal/presentation/project.go`, `project_test.go` + +- [ ] **Step 1: Write RED projection tests** proving deterministic machine evidence creates only neutral milestones (verification, commit, release, rollback, major error) and never invents reason, meaning, direction, or next action. + +```go +func TestProjectDoesNotPromoteAtomicFactsOrInventMeaning(t *testing.T) { + output := projectFromFacts(t, 40, withNoHumanSemantics()) + if len(output.Events) >= 40 { t.Fatalf("atomic facts leaked as milestones: %d", len(output.Events)) } + for _, event := range output.Events { if event.Why != "" || event.Next != "" { t.Fatalf("invented semantics: %+v", event) } } +} +``` + +- [ ] **Step 2: Write RED UI tests** for recent mode showing milestone total plus omitted count, “查看全部”, complete search/virtual list mode, and distinct `机器验证`/`人工确认` source labels. + +```ts +it("shows the milestone total when recent mode is compact", () => { + const panel = renderEvolution(modelWithMilestones(73), { ...defaultViewState(), fullHistory: false }, noopUpdate); + expect(panel.textContent).toContain("共 73 个里程碑"); + expect(panel.textContent).toContain("查看全部"); +}); +``` + +- [ ] **Step 3: Run RED:** `go test ./internal/presentation -run Milestone -count=1 && (cd obsidian-plugin && npm test -- large-history.test.ts view.test.ts)`. +- [ ] **Step 4: Implement typed milestone selection and explicit totals.** Remove atomic event-ID lists from human Markdown and browser cards; keep evidence identity behind the Session query surface. + +```ts +const visibleMilestones = state.fullHistory ? filteredMilestones : filteredMilestones.slice(0, RECENT_MILESTONE_LIMIT); +heading.append(element("span", { text: `共 ${filteredMilestones.length} 个里程碑` })); +if (!state.fullHistory && visibleMilestones.length < filteredMilestones.length) heading.append(showAllButton(update)); +``` + +- [ ] **Step 5: Run Go/plugin full gates and commit when authorized** with message `feat: keep project evolution complete and readable`. + +--- + +### Task 5: Render coverage, full virtual list, and index-only fallback + +**Files:** +- Create: `obsidian-plugin/src/view/render-sessions.ts` +- Modify: `obsidian-plugin/src/view/virtual-list.ts` +- Modify: `obsidian-plugin/src/styles.css` +- Create: `obsidian-plugin/tests/all-sessions-view.test.ts` +- Modify: `obsidian-plugin/tests/large-history.test.ts`, `styles.test.ts`, `accessibility.test.ts` + +- [ ] **Step 1: Write RED DOM tests** for the coverage line `共 154 · 完整 140 · 部分 8 · 错误 4 · 未处理 2`, all local filters, null timestamps, warnings, provider badges, source unavailable, and zero-result messaging. + +```ts +it("shows the complete coverage instead of the rendered window size", () => { + const root = renderSessions(modelWith154Sessions(), defaultSessionViewState(), noopUpdate); + expect(root.querySelector("[data-role=session-coverage]")?.textContent).toContain("共 154"); + expect(root.querySelectorAll("[data-session-row]").length).toBeLessThan(200); +}); +``` +- [ ] **Step 2: Add a 10,000-entry virtualization test.** Assert the model total remains 10,000, the rendered window stays below 200 rows, PageUp/PageDown/Home/End reach correct ordinals, and selection is full provider/session identity. +- [ ] **Step 3: Add no-CLI tests.** The 154 index rows remain navigable; deep controls are disabled; exactly one “配置 SessionReviewer CLI” recovery action is exposed. +- [ ] **Step 4: Run RED:** `cd obsidian-plugin && npm test -- all-sessions-view.test.ts large-history.test.ts accessibility.test.ts styles.test.ts`. +- [ ] **Step 5: Implement coverage cards, filters, virtual rows, focus management, ARIA list semantics, and theme-token CSS.** Never use `slice(-N)` to define the logical dataset. + +```ts +const visible = virtualWindow(filteredSessions, state.sessionOrdinal, viewportRows, overscanRows); +list.setAttribute("aria-setsize", String(filteredSessions.length)); +visible.forEach(({ item, ordinal }) => list.append(renderSessionRow(item, ordinal, filteredSessions.length))); +``` +- [ ] **Step 6: Run `npm run check` and commit when authorized** with message `feat: render complete all sessions view`. + +--- + +### Task 6: Render summaries and bounded event pages + +**Files:** +- Modify: `obsidian-plugin/src/view/render-sessions.ts` +- Create: `obsidian-plugin/src/state/session-detail.ts`, `session-detail.test.ts` +- Create: `obsidian-plugin/tests/session-detail-view.test.ts` + +**Interfaces:** + +```ts +type SessionDetailState = + | { kind: "idle" } + | { kind: "loading"; key: SessionIdentity } + | { kind: "ready"; summary: SessionSummaryV1; page?: SessionEventPageV1 } + | { kind: "unavailable"; reason: string } + | { kind: "stale"; requestedOrdinal: number }; +``` + +- [ ] **Step 1: Write RED interaction tests** for opening a Session, summary section omitted counts, next/previous/first/last page, ordinal jump, 2,438-event range labels, loading cancellation when selection changes, and CLI unavailable. + +```ts +it("shows a bounded middle page range", async () => { + cli.events.resolve(eventPage({ total: 2438, rangeStart: 1001, rangeEnd: 1100 })); + await openSessionAndJump(view, 1001); + expect(view.textContent).toContain("当前 1,001–1,100 / 共 2,438 条"); +}); +``` +- [ ] **Step 2: Add stale-cursor RED test.** Return `stale_cursor`, reload the repository, verify the same identity still exists, request the closest valid ordinal in the new generation, and announce the refresh; if identity vanished, return to the list without selecting a neighbor. +- [ ] **Step 3: Run RED:** `cd obsidian-plugin && npm test -- session-detail.test.ts session-detail-view.test.ts`. +- [ ] **Step 4: Implement one-request-at-a-time detail state** with request tokens/abort semantics, explicit range/total labels, coverage/omitted notices, and no retained full event history. + +```ts +const requestId = ++this.latestRequest; + const page = await this.cli.sessionEvents(this.projectId, key, generation, request); +if (requestId !== this.latestRequest || !sameSession(key, this.selected)) return; +this.state = { kind: "ready", summary: this.summary, page }; +``` +- [ ] **Step 5: Run `npm run check` and commit when authorized** with message `feat: browse bounded session details`. + +--- + +### Task 7: Add branch, file, and error search without weakening local filters + +**Files:** +- Modify: `obsidian-plugin/src/view/render-sessions.ts` +- Modify: `obsidian-plugin/src/state/session-filter.ts` +- Create: `obsidian-plugin/tests/session-search-view.test.ts` + +- [ ] **Step 1: Write RED tests** for literal branch/file/error queries, 256-byte boundary, paged matches, generation refresh, no-CLI disabled state, and local filter intersection with server-returned identities. + +```ts +it("intersects semantic hits with local provider filters", async () => { + cli.search.resolve(searchPage(hit("codex", "s1"), hit("claude", "s2"))); + const rows = await submitSearch(viewWithProviderFilter("claude"), "file", "src/app.ts"); + expect(rows).toEqual(["claude/s2"]); +}); +``` +- [ ] **Step 2: Run RED:** `cd obsidian-plugin && npm test -- session-search-view.test.ts`. +- [ ] **Step 3: Implement debounced explicit-submit search** using only `CliRunner.sessionSearch`; never place query text in a path, HTML, selector, or executable argument position without text escaping. + +```ts +const response = await cli.sessionSearch(model.review.projectId, model.generationId, { kind, query, limit: 100 }); +const hitKeys = new Set(response.items.map((item) => `${item.provider}\u0000${item.sessionId}`)); +const visible = locallyFiltered.filter((row) => hitKeys.has(`${row.provider}\u0000${row.sessionId}`)); +``` +- [ ] **Step 4: Show server match total/current range separately from local index filter total.** Clearing semantic search restores the complete locally filtered set without rescanning. +- [ ] **Step 5: Run `npm run check` and commit when authorized** with message `feat: search session facts from Obsidian`. + +--- + +### Task 8: Installed-bundle Obsidian acceptance + +**Files:** +- Modify: `obsidian-plugin/manifest.json`, `versions.json`, `package.json` only if a version bump is authorized +- Create: `docs/session-review/obsidian-all-sessions-acceptance.md` + +- [ ] **Step 1: Build:** `cd obsidian-plugin && npm run check`; install the resulting `main.js`, `manifest.json`, and `styles.css` into a disposable real Vault. +- [ ] **Step 2: Open a project with at least 154 indexed Sessions.** Verify tab order, exact coverage totals, earliest and latest Session reachability, filters, keyboard navigation, and stable selection after reload. +- [ ] **Step 3: Open the 2,438-event Session.** Verify summary, first/middle/last page, current range/total, no UI freeze, and bounded DOM node count. +- [ ] **Step 4: Temporarily invalidate the configured CLI.** Verify full index/local filters remain, deep controls disable with one recovery action, then recover after restoring the CLI. +- [ ] **Step 5: Trigger a new generation while a detail page is open.** Verify stale recovery stays on the same identity/closest ordinal or returns safely to the list. +- [ ] **Step 6: Validate light/dark themes, 100%/150% zoom, and macOS/Windows Obsidian Desktop.** Record screenshots, plugin bundle hashes, Vault fixture generation ID, and results in the acceptance document. diff --git a/docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md b/docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md new file mode 100644 index 0000000..58fd6f5 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md @@ -0,0 +1,346 @@ +# Obsidian Context Gate 0 Contracts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Freeze and validate every v4 persistence, state-machine, CLI, migration, and provider-neutral contract before any of the four user-facing features are implemented. + +**Architecture:** Keep the existing immutable Observation/SessionView/ProjectView store as the factual layer. Add strict v4 wire contracts and validators at package boundaries, with TypeScript mirrors for Obsidian. Gate 0 defines data shapes and command grammars only; feature services are implemented by the four follow-on plans. + +**Tech Stack:** Go 1.26, JSON Schema, existing canonical JSON/digest helpers, TypeScript 5.8, Vitest, Obsidian 1.13. + +**Spec:** `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` + +## Global Constraints + +- Start from the released `0.3.5` v3 architecture at commit `ea5b1ba` in isolated branch `codex/obsidian-context-v4`. The original dirty worktree remains untouched; do not reset, checkout, clean, or overwrite it. +- This plan is the prerequisite for the four feature plans dated 2026-09-04. Do not implement UI behavior here. +- Generic schemas use `(provider, session_id)` identity and never constrain provider to `codex`. +- Unknown prices are `null`, never numeric zero. Human-confirmed semantics and deterministic machine facts remain separate. +- All decoders reject duplicate JSON keys, unknown fields, oversized input, invalid UTF-8, non-canonical enums, inconsistent counts, and trailing JSON. +- Tests use fixtures and temporary directories only. Never read live Session stores or a real Vault. +- Every task runs focused tests first, then `go test ./...`, `go vet ./...`, `go mod tidy -diff`; plugin tasks also run `npm run check` in `obsidian-plugin`. +- Commit steps are conditional on the execution environment permitting Git mutation and the user authorizing it; otherwise record the checkpoint and continue without committing. + +## File Structure and Ownership + +- `schemas/`: normative JSON Schema documents for seven persisted/read contracts plus the `pricing-supplement-v1` input contract. +- `internal/reviewv4/`: review-presentation-v4 and machine-ledger-v4 types, codecs, cross-record invariants. +- `internal/sessionindex/`: session-index-v1 types and validation. +- `internal/inspect/`: session-summary-v1 and session-event-page-v1 response types. +- `internal/annotation/`: agent-annotation-v1 candidate and extraction-run types. +- `internal/pricing/`: pricing-snapshot-v1 types and validation only. +- `internal/cli/contracts.go`: command grammar, bounded scalar validators, and stable error codes; no service implementation. +- `obsidian-plugin/src/contracts/review-v4.ts`: TypeScript mirrors used by later repository and view work. +- `testdata/contracts/v4/` and `obsidian-plugin/tests/fixtures/v4/`: shared valid/invalid compatibility fixtures. + +## Plan Set Coverage + +| Spec area | Owning plan | +|---|---| +| Principles, version boundary, persisted contracts, state enums, compatibility matrix | This Gate 0 plan | +| Complete cumulative Sessions, provider fan-in, four-file publication, summaries/events/search | `2026-09-04-session-index-publication-query.md` | +| Four-tab order, readable evolution, complete virtual list, CLI degradation, installed Obsidian behavior | `2026-09-04-obsidian-all-sessions-view.md` | +| Human decisions/agreements, candidate extraction/CAS, three-file human publication | `2026-09-04-decisions-and-candidates.md` | +| ModelPriceWatch cache/matching, billable quantities, immutable snapshots, supplements, usage cards | `2026-09-04-modelpricewatch-pricing.md` | + +--- + +### Task 1: Freeze the seven schemas and shared enums + +**Files:** +- Create: `schemas/review-presentation-v4.schema.json` +- Create: `schemas/machine-ledger-v4.schema.json` +- Create: `schemas/session-index-v1.schema.json` +- Create: `schemas/session-summary-v1.schema.json` +- Create: `schemas/session-event-page-v1.schema.json` +- Create: `schemas/agent-annotation-v1.schema.json` +- Create: `schemas/pricing-snapshot-v1.schema.json` +- Create: `schemas/pricing-supplement-v1.schema.json` +- Create: `testdata/contracts/v4/*.json` +- Modify: `internal/memory/api_compat_test.go` + +**Interfaces:** The persisted fields and enums are exactly those in spec sections 15–17. `pricing-supplement-v1` is an input contract, not a persisted eighth artifact. `session-index-v1.coverage` enforces all three sum/length invariants. Decision, candidate, processing, source-availability, and price states are closed enums. + +- [ ] **Step 1: Add valid minimum fixtures and one invalid fixture per invariant** + +```go +func TestV4ContractFixtures(t *testing.T) { + for _, name := range []string{"review-presentation-v4", "machine-ledger-v4", "session-index-v1", "session-summary-v1", "session-event-page-v1", "agent-annotation-v1", "pricing-snapshot-v1", "pricing-supplement-v1"} { + validateFixture(t, "../../testdata/contracts/v4/"+name+".valid.json", name) + rejectFixture(t, "../../testdata/contracts/v4/"+name+".invalid.json", name) + } +} +``` + +The minimum `session-index-v1` schema starts with the concrete closed shape below and expands every referenced definition in the same file: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/session-index-v1.schema.json", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "digest", "project_id", "generation_id", "project_view_digest", "generated_at", "sort_version", "coverage", "sessions"], + "properties": { + "schema_version": { "const": 1 }, + "sort_version": { "const": "started-at-desc-null-last-provider-session-v1" }, + "sessions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/session" } } + } +} +``` + +- [ ] **Step 2: Run RED** + +Run: `go test ./internal/memory -run TestV4ContractFixtures -count=1` + +Expected: FAIL because the schemas and fixtures do not exist. + +- [ ] **Step 3: Implement the schemas with `additionalProperties: false` at every object boundary** + +Use byte-length validation in Go for limits JSON Schema cannot express reliably. Keep nullable timestamps/rates explicit with `type: ["string", "null"]` or `type: ["number", "null"]`. + +- [ ] **Step 4: Run GREEN and full Go gates** + +Run: `gofmt -w internal/memory && go test ./internal/memory -count=1 && go test ./... && go vet ./... && go mod tidy -diff` + +- [ ] **Step 5: Commit the contract checkpoint when authorized** + +```bash +git add schemas testdata/contracts internal/memory/api_compat_test.go +git commit -m "feat: freeze project context v4 schemas" +``` + +--- + +### Task 2: Add strict Go wire types and validators + +**Files:** +- Create: `internal/reviewv4/types.go`, `codec.go`, `validate.go`, `codec_test.go` +- Create: `internal/sessionindex/types.go`, `validate.go`, `validate_test.go` +- Create: `internal/inspect/types.go`, `validate.go`, `validate_test.go` +- Create: `internal/annotation/types.go`, `validate.go`, `validate_test.go` +- Create: `internal/pricing/types.go`, `validate.go`, `validate_test.go` + +**Interfaces:** + +```go +type SessionKey struct { Provider, SessionID string } +type ProcessingState string // complete|partial|error|unprocessed +type DecisionStatus string // active|superseded|archived +type CandidateStatus string // pending|confirmed|ignored|not_decision|stale +type PriceStatus string // pending|current|promotion|stale_estimate|manual_supplement|ambiguous|legacy_unverified|superseded + +func sessionindex.Parse([]byte) (Document, error) +func sessionindex.Render(Document) ([]byte, error) +func reviewv4.Parse(review, history, ledger []byte) (Accepted, error) +func reviewv4.RenderLedger(MachineLedger) ([]byte, error) +func inspect.RenderSummary(SessionSummary) ([]byte, error) +func inspect.RenderEventPage(SessionEventPage) ([]byte, error) +func annotation.Validate(StoreRecord) error +func pricing.ValidateSnapshot(Snapshot) error +``` + +- [ ] **Step 1: Write table tests for identity, nullability, graph, and coverage invariants** + +```go +func TestSessionIndexRejectsCrossProviderDuplicateOnlyWhenFullKeyMatches(t *testing.T) { + doc := minimumIndex() + doc.Sessions = []Entry{{Provider: "codex", SessionID: "same"}, {Provider: "claude", SessionID: "same"}} + rebuildCoverage(&doc) + if err := Validate(doc); err != nil { t.Fatal(err) } + doc.Sessions[1].Provider = "codex" + if err := Validate(doc); err == nil { t.Fatal("accepted duplicate provider/session identity") } +} +``` + +Also reject decision supersession cycles, `pricing_complete=true` with a nil rate or total, free price represented as null, and cursors present when `total=0`. + +- [ ] **Step 2: Run RED** + +Run: `go test ./internal/reviewv4 ./internal/sessionindex ./internal/inspect ./internal/annotation ./internal/pricing -count=1` + +Expected: FAIL because the packages do not exist. + +- [ ] **Step 3: Implement strict codecs using one canonical JSON helper** + +Render maps/slices deterministically, normalize nil collections to empty JSON arrays where required, calculate digest after omitting the digest field, and compare the decoded semantic value after render. + +- [ ] **Step 4: Run focused and full GREEN gates** + +Run: `gofmt -w internal/reviewv4 internal/sessionindex internal/inspect internal/annotation internal/pricing && go test ./internal/reviewv4 ./internal/sessionindex ./internal/inspect ./internal/annotation ./internal/pricing -count=1 && go test ./... && go vet ./... && go mod tidy -diff` + +- [ ] **Step 5: Commit when authorized** + +```bash +git add internal/reviewv4 internal/sessionindex internal/inspect internal/annotation internal/pricing +git commit -m "feat: add strict v4 wire validators" +``` + +--- + +### Task 3: Freeze CLI grammar and failure codes without feature side effects + +**Files:** +- Create: `internal/cli/contracts.go`, `contracts_test.go` +- Modify: `internal/cli/run_test.go` + +**Interfaces:** + +```go +const MaxInspectPageSize = 100 +const MaxInspectQueryBytes = 256 +const MaxDecisionInputBytes = 64 << 10 +const MaxOpaqueCursorBytes = 4096 + +type ContractError struct { Code string; Message string } +func ParseInspectContract(args []string) (InspectRequest, error) +func ParseDecisionContract(args []string) (DecisionRequest, error) +``` + +Stable codes include `invalid_argument`, `generation_mismatch`, `stale_cursor`, `anchor_out_of_range`, `response_too_large`, `candidate_revision_conflict`, `review_preimage_conflict`, `session_index_capacity_exceeded`, and `migration_preview_stale`. Migration parsing fixes `--confirm-migration --expected-preview-digest `; plain `sync` cannot authorize v3→v4. + +- [ ] **Step 1: Add exact-argv allowlist tests for every command in spec 17.3–17.4** + +```go +func TestParseInspectContractRejectsMixedCursorAndAnchor(t *testing.T) { + _, err := ParseInspectContract([]string{"session-events", "--project-id", "project-p", "--provider", "codex", "--session-id", "s1", "--expected-generation-id", "g1", "--cursor", "opaque", "--anchor", "2", "--limit", "100", "--json"}) + if codeOf(err) != "invalid_argument" { t.Fatalf("code=%q err=%v", codeOf(err), err) } +} +``` + +- [ ] **Step 2: Run RED:** `go test ./internal/cli -run 'Test(ParseInspect|ParseDecision)Contract' -count=1` and expect missing parsers. +- [ ] **Step 3: Implement grammar-only parsing; do not add root dispatch or storage calls yet.** Reject arbitrary file arguments, simultaneous cursor/anchor, limit outside 1–100, oversized UTF-8 query/cursor, and unknown enum values. + +```go +func ParseInspectContract(args []string) (InspectRequest, error) { + if len(args) == 0 { return InspectRequest{}, contractError("invalid_argument", "inspect subcommand is required") } + switch args[0] { + case "session-summary": return parseSessionSummaryArgs(args[1:]) + case "session-events": return parseSessionEventArgs(args[1:]) + case "session-search": return parseSessionSearchArgs(args[1:]) + default: return InspectRequest{}, contractError("invalid_argument", "unknown inspect subcommand") + } +} +``` +- [ ] **Step 4: Run GREEN:** `gofmt -w internal/cli && go test ./internal/cli -count=1 && go test ./... && go vet ./... && go mod tidy -diff`. +- [ ] **Step 5: Commit when authorized:** `git add internal/cli && git commit -m "feat: freeze inspect and decision command contracts"`. + +--- + +### Task 4: Mirror contracts in the Obsidian plugin + +**Files:** +- Create: `obsidian-plugin/src/contracts/review-v4.ts` +- Create: `obsidian-plugin/src/data/contracts-v4.ts` +- Create: `obsidian-plugin/tests/contracts-v4.test.ts` +- Create: `obsidian-plugin/tests/fixtures/v4/*.json` + +**Interfaces:** + +```ts +export type ViewKind = "evolution" | "decisions" | "sessions" | "usage"; +export type SessionIdentity = Readonly<{ provider: string; sessionId: string }>; +export function parseMachineLedgerV4(source: string): MachineLedgerV4; +export function parseSessionIndexV1(source: string): SessionIndexV1; +export function parseSessionSummaryV1(source: string): SessionSummaryV1; +export function parseSessionEventPageV1(source: string): SessionEventPageV1; +export function parseCandidateListV1(source: string): CandidateListV1; +``` + +- [ ] **Step 1: Write fixture parity tests** that accept the same valid fixtures and reject the same invalid cases as Go. + +```ts +it("rejects a mixed-generation index", () => { + const value = validIndex(); + value.generation_id = "generation-other"; + expect(() => assertSnapshotBindings(validLedger(), value)).toThrow(/generation/i); +}); +``` + +- [ ] **Step 2: Run RED:** `cd obsidian-plugin && npm test -- contracts-v4.test.ts`; expect missing modules. +- [ ] **Step 3: Implement strict parsers** with byte limits before `JSON.parse`, recursive allowed/required-key checks, safe-integer validation, and full `(provider, sessionId)` uniqueness. + +```ts +export function parseSessionIndexV1(source: string): SessionIndexV1 { + if (Buffer.byteLength(source, "utf8") > (64 << 20)) throw new Error("session index exceeds 67108864 bytes"); + rejectDuplicateJsonKeys(source); + const root = object(JSON.parse(source), "$", SESSION_INDEX_KEYS, SESSION_INDEX_KEYS); + const parsed = parseSessionIndexRoot(root); + validateSessionIndexCoverage(parsed); + return parsed; +} +``` +- [ ] **Step 4: Run GREEN:** `cd obsidian-plugin && npm run check`. +- [ ] **Step 5: Commit when authorized:** `git add obsidian-plugin/src/contracts obsidian-plugin/src/data obsidian-plugin/tests && git commit -m "feat: mirror v4 contracts in Obsidian"`. + +--- + +### Task 5: Prove v2/v3/v4 compatibility and migration fixtures + +**Files:** +- Create: `internal/reviewv4/migrate.go`, `migrate_test.go` +- Create: `testdata/contracts/migration/v2-*`, `v3-*`, `v4-*` +- Modify: `internal/migrationv3/plan.go`, `plan_test.go` +- Modify: `internal/reviewv2/v3_test.go` +- Modify: `internal/cli/sync.go`, `sync_test.go` +- Modify: `internal/sync/service.go`, `service_test.go` + +**Interfaces:** + +```go +type MigrationPreview struct { + SourceVersion int + TargetVersion int + PreservedDecisionIDs []string + DefaultedFields map[string][]string + RequiresSessionIndex bool +} +func PreviewMigration(review, history, ledger []byte) (MigrationPreview, error) +func MigrateAcceptedV3(review, history, ledger, sessionIndex []byte) (reviewv4.Accepted, error) +func MigrationPreviewDigest(MigrationPreview) string +``` + +- [ ] **Step 1: Write RED fixtures for the complete matrix:** v2 stays readable/migratable; v3 requires explicit dry-run and a bound session index; v4 opens directly; newer/partial/mixed generations fail closed. + +```go +func TestMigrateAcceptedV3PreservesDecisionWithoutInventingFields(t *testing.T) { + got := migrateDecision(reviewv2.Decision{ID: "decision-1", Title: "Keep v3", Rationale: "because", Impact: "scope"}) + if got.Provenance != "migrated" || got.Pinned || len(got.Supersedes) != 0 || len(got.SessionRefs) != 0 { t.Fatalf("invented migration data: %+v", got) } +} +``` + +- [ ] **Step 2: Run RED:** `go test ./internal/reviewv4 ./internal/migrationv3 ./internal/reviewv2 -run 'Migration|Compatibility' -count=1`. +- [ ] **Step 3: Implement pure preview/migration functions.** Map old decisions to `kind=decision`, `status=active` unless the old status maps exactly, empty new fields, `provenance=migrated`, `pinned=false`, `revision=1`; never infer reasons, relationships, or Sessions. + +```go +func migrateDecision(old reviewv2.Decision) Decision { + return Decision{ID: old.ID, Kind: "decision", OccurredAt: old.OccurredAt, Title: old.Title, Rationale: old.Rationale, Impact: old.Impact, Status: mapLegacyStatus(old.Status), Supersedes: []string{}, MilestoneIDs: []string{}, SessionRefs: []SessionKey{}, Provenance: "migrated", Pinned: false, Revision: 1} +} +``` + +- [ ] **Step 4: Add explicit CLI migration confirmation.** `sync --dry-run --json` returns the preview and digest without writes. `sync --confirm-migration --expected-preview-digest --json` recomputes under the project lock and returns `migration_preview_stale` if source bytes, target preimages, generation, or SessionView dependencies changed. Plain `sync` reports `migration_required` for v3. + +```go +if request.ConfirmMigration { + current := buildMigrationPreviewUnderLock(request.ProjectID) + if MigrationPreviewDigest(current) != request.ExpectedPreviewDigest { return Result{}, contractError("migration_preview_stale", "migration preview changed") } + return applyMigrationPlan(ctx, current) +} +``` + +- [ ] **Step 5: Run full gates:** `gofmt -w internal/reviewv4 internal/migrationv3 internal/reviewv2 internal/cli internal/sync && go test ./... && go vet ./... && go mod tidy -diff && (cd obsidian-plugin && npm run check)`. +- [ ] **Step 6: Commit when authorized:** `git add internal/reviewv4 internal/migrationv3 internal/reviewv2 internal/cli internal/sync testdata/contracts/migration && git commit -m "feat: prove v4 compatibility matrix"`. + +--- + +### Task 6: Gate 0 completion audit + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` +- Create: `docs/session-review/gate-0-evidence.md` + +- [ ] **Step 1: Run the complete Go and plugin gates:** `go test ./... && go vet ./... && go mod tidy -diff && (cd obsidian-plugin && npm run check)`. +- [ ] **Step 2: Run schema fixture tests on macOS and Windows CI.** Expected: eight valid fixtures accepted identically; invalid fixtures rejected with stable codes. +- [ ] **Step 3: Search for forbidden placeholder tokens:** `rg -n $'\x54\x42\x44|\x54\x4f\x44\x4f|\x46\x49\x58\x4d\x45|\x69\x6d\x70\x6c\x65\x6d\x65\x6e\x74\x20\x6c\x61\x74\x65\x72|\x66\x69\x6c\x6c\x20\x69\x6e\x20\x64\x65\x74\x61\x69\x6c\x73|\x68\x61\x6e\x64\x6c\x65\x20\x65\x64\x67\x65\x20\x63\x61\x73\x65\x73|\x73\x69\x6d\x69\x6c\x61\x72\x20\x74\x6f' schemas internal/reviewv4 internal/sessionindex internal/inspect internal/annotation internal/pricing obsidian-plugin/src/contracts/review-v4.ts` and require no hit. +- [ ] **Step 4: Record exact commit, commands, pass counts, fixture list, and known non-Gate-0 work in `docs/session-review/gate-0-evidence.md`.** +- [ ] **Step 5: Mark Gate 0 complete in the spec only after every preceding check passes; commit documentation when authorized.** diff --git a/docs/superpowers/plans/2026-09-04-session-index-publication-query.md b/docs/superpowers/plans/2026-09-04-session-index-publication-query.md new file mode 100644 index 0000000..8742dfb --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-session-index-publication-query.md @@ -0,0 +1,306 @@ +# Session Index Publication and Query Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Publish a complete cumulative Session index with every successful scan and expose bounded, generation-bound summary, event, and search queries without copying raw transcripts into the Vault. + +**Architecture:** Build `session-index-v1` from the current generation manifest plus the last accepted index, preserve absent identities as `source_unavailable`, and add it as the fourth atomic projection file. A read-only inspect service loads immutable SessionView/Observation objects by authenticated digest and returns deterministic summaries or cursor pages. Provider orchestration merges enabled adapters while isolating provider-level availability failures. + +**Tech Stack:** Go 1.26, existing `memory`, `memorystore`, `scan`, `presentation`, `publication`, `pathguard`, `source` adapters, JSON CLI. + +**Spec:** `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` + +## Global Constraints + +- Prerequisite: `2026-09-04-obsidian-context-gate-0-contracts.md` is complete. +- Begin from released 0.3.5 v3 (`ea5b1ba`) in the isolated implementation worktree and preserve the original dirty user changes. No destructive Git cleanup. +- `session-index.json` is complete or not published. Limits are 65,536 entries and 64 MiB; overflow returns `session_index_capacity_exceeded` and retains the previous accepted generation. +- Index order is `started_at desc nulls last, provider asc, session_id asc`. Identity is always `(project_id, provider, session_id)`. +- Raw messages, hidden reasoning, instructions, absolute paths, secrets, and full tool output never enter the index, Vault, or inspect response. +- Claude Code and OpenCode end-to-end acceptance is blocked until their real SourceAdapters exist. If absent, execute Tasks 5 and 6 of `docs/superpowers/plans/2026-08-30-multi-agent-session-review.md`; UI labels alone do not satisfy this plan. +- Every task runs focused tests, all Go gates, and conditional authorized commits as described in Gate 0. + +## File Structure and Ownership + +- `internal/source/manager.go`: fan-in of enabled SourceAdapters and provider diagnostics. +- `internal/sessionindex/build.go`: cumulative index construction and stable ordering. +- `internal/presentation/render.go`: creates the four-file scan render plan. +- `internal/publication/`: journals, syncs, verifies, and recovers the fourth file. +- `internal/inspect/service.go`: read-only generation-bound queries. +- `internal/inspect/cursor.go`: authenticated opaque page cursors and ordinal anchors. +- `internal/cli/inspect.go`: strict root dispatch and JSON diagnostics. + +--- + +### Task 1: Compose enabled providers without cross-provider data loss + +**Files:** +- Create: `internal/source/manager.go`, `manager_test.go` +- Modify: `internal/scan/service.go`, `service_test.go` +- Modify: `internal/contextupdate/service.go` +- Modify: `internal/config/config.go`, `config_test.go` + +**Interfaces:** + +```go +type NamedAdapter struct { Provider string; Adapter source.Adapter; Required bool } +type ProviderDiagnostic struct { Provider, Code string } +func DiscoverAll(ctx context.Context, adapters []NamedAdapter) (source.Discovery, []ProviderDiagnostic, error) + +type scan.Options struct { + // existing fields + Adapters []source.NamedAdapter +} +``` + +- [ ] **Step 1: Write RED tests** for Codex+Claude+OpenCode candidates with the same native Session ID, one uninstalled optional provider, and one corrupt configured provider. Assert optional unavailability produces a provider diagnostic while candidates from other providers remain; corruption of a configured provider fails closed. + +```go +func TestDiscoverAllKeepsOtherProvidersWhenOptionalAdapterUnavailable(t *testing.T) { + got, diagnostics, err := DiscoverAll(context.Background(), []NamedAdapter{ + {Provider: "codex", Adapter: fakeAdapter{sessions: []string{"same"}}, Required: true}, + {Provider: "claude", Adapter: fakeAdapter{err: ErrProviderUnavailable}, Required: false}, + {Provider: "opencode", Adapter: fakeAdapter{sessions: []string{"same"}}, Required: false}, + }) + if err != nil || len(got.Candidates) != 2 || diagnostics[0].Provider != "claude" { t.Fatalf("got=%+v diagnostics=%+v err=%v", got, diagnostics, err) } +} +``` +- [ ] **Step 2: Run RED:** `go test ./internal/source ./internal/scan ./internal/contextupdate -run 'Provider|Adapter' -count=1`. +- [ ] **Step 3: Implement deterministic fan-in.** Sort adapters by provider, candidates by provider/session, reject provider spoofing, and replace the singular `Options.Adapter` use. Instantiate only real verified adapters in `contextupdate`; do not synthesize empty Claude/OpenCode adapters. + +```go +for _, named := range sortedAdapters(adapters) { + discovered, err := named.Adapter.Discover(ctx) + if errors.Is(err, ErrProviderUnavailable) && !named.Required { diagnostics = append(diagnostics, ProviderDiagnostic{Provider: named.Provider, Code: "provider_unavailable"}); continue } + if err != nil { return source.Discovery{}, diagnostics, fmt.Errorf("discover %s: %w", named.Provider, err) } + if err := appendVerifiedProvider(&combined, named.Provider, discovered); err != nil { return source.Discovery{}, diagnostics, err } +} +``` +- [ ] **Step 4: Run GREEN:** `gofmt -w internal/source internal/scan internal/contextupdate internal/config && go test ./internal/source ./internal/scan ./internal/contextupdate ./internal/config -count=1 && go test ./... && go vet ./... && go mod tidy -diff`. +- [ ] **Step 5: Commit when authorized:** `git add internal/source internal/scan internal/contextupdate internal/config && git commit -m "feat: compose enabled session providers"`. + +--- + +### Task 2: Build the cumulative complete Session index + +**Files:** +- Create: `internal/sessionindex/build.go`, `build_test.go` +- Modify: `internal/memorystore/store.go`, `store_test.go` +- Modify: `internal/scan/service.go`, `service_test.go` + +**Interfaces:** + +```go +type BuildInput struct { + ProjectView memory.ProjectView + Manifest memory.GenerationManifest + SessionViews map[SessionKey]*memory.SessionView + Previous *sessionindex.Document + GeneratedAt time.Time +} +func Build(BuildInput) (sessionindex.Document, error) +``` + +Processing-state mapping is explicit: clean indexed -> `complete`; indexed with diagnostics or unprojected/undecodable facts -> `partial`; unreadable/missing/ambiguous/unsupported terminal failure without a usable SessionView -> `error`; discovered but not processed -> `unprocessed`. Source availability is computed independently. + +- [ ] **Step 1: Write RED tests with 154 mixed Sessions.** Include old identities absent from the new discovery, null start times, duplicate native IDs across providers, partial/errored sessions, and an unchanged prior entry. Assert no identity disappears and the coverage sums equal total. + +```go +func TestBuildRetainsAbsentPriorSessionAsSourceUnavailable(t *testing.T) { + previous := indexWith(entry("claude", "old", "complete", "available")) + got, err := Build(BuildInput{ProjectView: projectView(), Manifest: manifest(), Previous: &previous, GeneratedAt: fixedTime}) + if err != nil { t.Fatal(err) } + row := requireEntry(t, got, SessionKey{Provider: "claude", SessionID: "old"}) + if row.ProcessingState != "complete" || row.SourceAvailability != "source_unavailable" { t.Fatalf("row=%+v", row) } +} +``` +- [ ] **Step 2: Add capacity RED cases:** 65,537 entries and a rendered document above 64 MiB both return `session_index_capacity_exceeded`; the memorystore published pointer remains unchanged. +- [ ] **Step 3: Run RED:** `go test ./internal/sessionindex ./internal/scan ./internal/memorystore -run 'Build|Capacity|Cumulative' -count=1`. +- [ ] **Step 4: Implement immutable build and canonical render.** Preserve prior factual counts for absent sources, change only source availability/last-seen fields, and bind digest/project/generation/ProjectView digest before storing the object. + +```go +for key, prior := range previousByKey(in.Previous) { + if _, seen := next[key]; seen { continue } + retained := prior + retained.SourceAvailability = sessionindex.SourceUnavailable + next[key] = retained +} +doc.Sessions = stableSessionOrder(maps.Values(next)) +doc.Coverage = calculateCoverage(doc.Sessions) +``` +- [ ] **Step 5: Run GREEN and commit when authorized:** `gofmt -w internal/sessionindex internal/scan internal/memorystore && go test ./internal/sessionindex ./internal/scan ./internal/memorystore -count=1 && go test ./... && go vet ./... && go mod tidy -diff`; then `git add internal/sessionindex internal/scan internal/memorystore && git commit -m "feat: build cumulative session index"`. + +--- + +### Task 3: Publish and recover the four-file atomic set + +**Files:** +- Modify: `internal/reviewv2/types.go` or its post-Gate-0 compatibility shim +- Modify: `internal/presentation/render.go`, `render_test.go` +- Modify: `internal/publication/types.go`, `service.go`, `journal.go` +- Modify: `internal/publication/service_test.go`, `recovery_test.go`, `journal_test.go` +- Modify: `internal/contextupdate/service.go` + +**Interfaces:** + +```go +const SessionIndexRelativePath = "docs/session-review/.session-reviewer/session-index.json" + +type RenderInput struct { + // existing fields + SessionIndex []byte +} +``` + +- [ ] **Step 1: Extend render tests** to require exactly four scan files and exact expected/preimage bytes for `session-index.json`. + +```go +func TestRenderIncludesSessionIndexAsFourthAtomicFile(t *testing.T) { + plan := renderPlan(t, []byte(`{"schema_version":1}`)) + if len(plan.Files) != 4 || plan.Files[3].Relative != reviewv4.SessionIndexRelativePath { t.Fatalf("files=%+v", plan.Files) } +} +``` +- [ ] **Step 2: Extend journal crash-point tests** across each write, Project→Vault sync, verification, and rollback. Assert no observable mixed generation after recovery. +- [ ] **Step 3: Run RED:** `go test ./internal/presentation ./internal/publication ./internal/contextupdate -run 'SessionIndex|FourFile|Recovery' -count=1`. +- [ ] **Step 4: Implement the fourth mapping.** Add `.session-reviewer/session-index.json` to `vaultRelativePath`, proof hashes, verification, repair/status diagnostics, and the existing 64 MiB safe read ceiling. Replace comments and assertions that say “3 files”. + +```go +case reviewv4.SessionIndexRelativePath: + return path.Join(vaultReviewPath, ".session-reviewer/session-index.json") +``` +- [ ] **Step 5: Run GREEN and commit when authorized:** `gofmt -w internal/presentation internal/publication internal/contextupdate internal/reviewv2 && go test ./internal/presentation ./internal/publication ./internal/contextupdate -count=1 && go test ./... && go vet ./... && go mod tidy -diff`; then commit `feat: publish session index atomically`. + +--- + +### Task 4: Implement deterministic Session summaries + +**Files:** +- Create: `internal/inspect/service.go`, `summary.go`, `service_test.go`, `summary_test.go` +- Modify: `internal/memorystore/store.go` + +**Interfaces:** + +```go +type Store interface { + LoadPublished() (string, memory.GenerationManifest, error) + LoadObject(kind memorystore.ObjectKind, digest string) ([]byte, error) +} +type SummaryRequest struct { ProjectID, Provider, SessionID, ExpectedGenerationID string } +func (s *Service) SessionSummary(context.Context, SummaryRequest) (SessionSummary, error) +``` + +- [ ] **Step 1: Write RED tests** for dependency authentication, 32-item section caps, 512-byte excerpts, deterministic ordering, source-unavailable reuse, generation mismatch, and a malicious absolute path in an Observation field. + +```go +func TestSessionSummaryCapsSectionsAndReportsOmitted(t *testing.T) { + service := serviceWithObservations(40, func(i int) memory.ObservationSummary { return verification(i) }) + got, err := service.SessionSummary(context.Background(), summaryRequest()) + if err != nil { t.Fatal(err) } + if len(got.Verifications.Items) != 32 || got.Verifications.Total != 40 || got.Verifications.Omitted != 8 { t.Fatalf("section=%+v", got.Verifications) } +} +``` +- [ ] **Step 2: Run RED:** `go test ./internal/inspect -run SessionSummary -count=1`. +- [ ] **Step 3: Implement typed rule projection.** Load only digests referenced by the accepted index/manifest; emit phase boundaries, operations, verification, errors, and unresolved facts with per-section total/shown/omitted counts, rule ID/version, revision IDs, and dependency digest. + +```go +func boundedSection(items []inspect.Item) inspect.Section { + sort.SliceStable(items, func(i, j int) bool { return eventLess(items[i], items[j]) }) + total := len(items) + if total > 32 { items = items[:32] } + return inspect.Section{Total: total, Shown: len(items), Omitted: total-len(items), Items: items} +} +``` +- [ ] **Step 4: Run GREEN and commit when authorized:** `gofmt -w internal/inspect internal/memorystore && go test ./internal/inspect ./internal/memorystore -count=1 && go test ./... && go vet ./... && go mod tidy -diff`; then commit `feat: add deterministic session summaries`. + +--- + +### Task 5: Implement authenticated event pages and bounded search + +**Files:** +- Create: `internal/inspect/cursor.go`, `events.go`, `search.go` +- Create: `internal/inspect/cursor_test.go`, `events_test.go`, `search_test.go` + +**Interfaces:** + +```go +type EventRequest struct { ProjectID, Provider, SessionID, ExpectedGenerationID, Cursor string; Anchor, Limit int } +type SearchRequest struct { ProjectID, ExpectedGenerationID, QueryKind, Query, Cursor string; Limit int } +func (s *Service) SessionEvents(context.Context, EventRequest) (SessionEventPage, error) +func (s *Service) SessionSearch(context.Context, SearchRequest) (SearchPage, error) +``` + +Cursor payload contains project/provider/session/generation/sort-version/filter-digest/page-size/start ordinal and an HMAC; the opaque encoding is at most 4096 bytes. + +- [ ] **Step 1: Write RED paging tests** for empty, first, middle, last, exact multiples, anchor 0/total+1, cursor identity mixing, changed page size/filter, stale generation, tampering, and 101 limit. + +```go +func TestSessionEventsRejectsCursorFromAnotherProvider(t *testing.T) { + cursor := signedCursor(t, cursorPayload{ProjectID: "project-p", Provider: "codex", SessionID: "same", GenerationID: "g1", PageSize: 100}) + _, err := service.SessionEvents(context.Background(), EventRequest{ProjectID: "project-p", Provider: "claude", SessionID: "same", ExpectedGenerationID: "g1", Cursor: cursor, Limit: 100}) + if codeOf(err) != "stale_cursor" { t.Fatalf("err=%v", err) } +} +``` +- [ ] **Step 2: Write RED privacy/bounds tests.** A response above the configured byte ceiling fails `response_too_large`; search query above 256 UTF-8 bytes fails; returned events omit raw prompts, reasoning, paths, and tool output. +- [ ] **Step 3: Run RED:** `go test ./internal/inspect -run 'Cursor|SessionEvents|SessionSearch' -count=1`. +- [ ] **Step 4: Implement stable sort** `occurred_at asc, sequence asc, revision_id asc`, one-based ranges, null cursors at total zero, ordinal anchors, normalized literal text matching, and no filesystem interpretation of query text. + +```go +if request.Anchor != 0 && (request.Anchor < 1 || request.Anchor > total) { + return SessionEventPage{}, contractError("anchor_out_of_range", "anchor is outside the current result") +} +start := pageStart(request.Anchor, request.Limit, total) +return buildPage(sortedEvents, start, request.Limit, cursorSigner) +``` +- [ ] **Step 5: Run GREEN and commit when authorized:** `gofmt -w internal/inspect && go test ./internal/inspect -count=1 && go test ./... && go vet ./... && go mod tidy -diff`; then commit `feat: page and search session facts safely`. + +--- + +### Task 6: Expose strict read-only inspect CLI commands + +**Files:** +- Create: `internal/cli/inspect.go`, `inspect_test.go` +- Modify: `internal/cli/run.go`, `run_test.go` +- Modify: `internal/cli/contracts.go` + +- [ ] **Step 1: Add exact JSON acceptance tests** for all three commands, typed errors, stdout-only success, stderr-only diagnostics, exit 2 for syntax, and nonzero service failures. + +```go +func TestInspectSessionEventsRequiresExpectedGeneration(t *testing.T) { + code, _, stderr := runCLI("inspect", "session-events", "--project-id", "project-p", "--provider", "codex", "--session-id", "s1", "--limit", "100", "--json") + if code != 2 || !strings.Contains(stderr, "expected-generation-id") { t.Fatalf("code=%d stderr=%q", code, stderr) } +} +``` +- [ ] **Step 2: Run RED:** `go test ./internal/cli -run Inspect -count=1`. +- [ ] **Step 3: Add `inspect` root dispatch** and wire the Gate-0 parser to `inspect.Service`. Require `--json`, explicit project/provider/session/generation where specified, and reject unknown flags or file paths. + +```go +case "inspect": + return runInspect(args[1:], stdout, stderr, inspectDependencies()) +``` +- [ ] **Step 4: Run GREEN:** `gofmt -w internal/cli && go test ./internal/cli -count=1 && go test ./... && go vet ./... && go mod tidy -diff`. +- [ ] **Step 5: Commit when authorized:** `git add internal/cli && git commit -m "feat: expose bounded session inspection"`. + +--- + +### Task 7: Integration, performance, and cross-provider acceptance + +**Files:** +- Create: `test/sessionindex/gate_test.go` +- Create: `testdata/sessionindex/154-sessions/` +- Modify: `.github/workflows/ci.yml` +- Create: `docs/session-review/session-index-acceptance.md` + +- [ ] **Step 1: Run a 154-Session fixture scan twice** and assert first publication contains 154 index entries, second identical scan changes no canonical bytes, and every entry can resolve a summary or a typed unavailable/error response. + +```go +func TestGateSessionIndexIsCompleteAndRepeatable(t *testing.T) { + first := runFixtureScan(t, "testdata/sessionindex/154-sessions") + second := runFixtureScan(t, "testdata/sessionindex/154-sessions") + if first.Index.Coverage.Total != 154 || !bytes.Equal(first.IndexBytes, second.IndexBytes) { t.Fatalf("first=%+v second=%+v", first.Index.Coverage, second.Index.Coverage) } +} +``` +- [ ] **Step 2: Run a long-Session fixture with 2,438 events** and assert page ranges/cursors reach the final event without loading the full event set into the plugin-facing response. +- [ ] **Step 3: Run failure injection** at index capacity, one corrupt Session, one unavailable source, stale cursor, and each publication crash point. Verify previous generation remains usable. +- [ ] **Step 4: Run macOS and Windows CI:** `go test ./... && go vet ./... && go mod tidy -diff`. +- [ ] **Step 5: With real enabled adapters, scan one Codex, one Claude Code, and one OpenCode Session for the same project.** Record namespaced identities and provider-isolated failure behavior. If either non-Codex adapter is absent, mark this plan incomplete and execute the named prerequisite plan tasks. +- [ ] **Step 6: Record commands, timings, peak sizes, generation IDs, hashes, and screenshots/observations in `docs/session-review/session-index-acceptance.md`; commit evidence when authorized.** diff --git a/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md new file mode 100644 index 0000000..e8ee5a1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md @@ -0,0 +1,722 @@ +# SessionReviewer Obsidian 项目脉络、决策与价格查询设计 + +- 状态:用户复核通过,进入实施计划阶段 +- 日期:2026-09-04 +- 适用范围:SessionReviewer 零 Token 扫描、macOS/Windows Obsidian Desktop 项目脉络浏览器、Project/Vault 投影 +- 扩展:`2026-08-25-session-reviewer-project-evolution-browser-design.md` + +## 1. 背景与问题 + +SessionReviewer 0.3.0 已将可验证的零 Token 扫描与人类语义总结分层。这个方向解决了大量 Session 扫描被 Agent 生成失败中断的问题,但现有 Obsidian 表现仍然沿用“少量演进节点 + 关键决策卡片”模型,产生了以下语义断层: + +1. 超长 Session 或一个项目的多组 Sessions 在 Obsidian 中只显示最近少量条目,用户无法确认是否已扫全,也无法稳定地下钻到早期事件。 +2. 新的零 Token 扫描可以记录发生过的事实,但不会臆造“为什么这样决定”。因此新项目的“关键决策”为空,现有界面却没有说明这是能力边界而非扫描失败。 +3. 当前投影可把大量原子事件 ID 直接写进人类可读页面。这保留了索引,但破坏了“打开项目后快速恢复上下文”的产品目标。 +4. 模型价格变化频繁,本地固定价格表容易过期;未匹配价格又不应被当作零成本。 + +## 2. 产品原则 + +本设计固定以下边界: + +- **扫描负责完整**:发现、解码和索引所有可归属 Sessions,异常 Session 也必须可见。 +- **投影负责可读**:项目首页不堆叠原子事件,只呈现恢复工作所需的当前状态和里程碑。 +- **人负责确认语义**:正式的决策与约定必须由人创建、编辑或确认。 +- **AI 只负责候选**:AI 可按用户要求从新 Sessions 提炼决策候选,但不能自动升级为正式项目事实。 +- **截断必须显式**:任何列表、分页、摘要或保留策略都必须显示总量、当前范围和未展示数量,禁止静默丢弃。 +- **价格可追溯且不阻断扫描**:用量事实与价格查询分离;价格不可用时仍保留 Token 统计,但费用不得伪装为 `$0`。 + +## 3. Obsidian 信息架构 + +项目页顶部继续作为唯一恢复入口,展示: + +- 项目目标; +- 当前阶段; +- 当前状态; +- 下一步; +- 风险与待办; +- 扫描覆盖摘要。 + +主视图固定为以下顺序: + +1. **项目演进** +2. **决策与约定** +3. **全部 Sessions** +4. **用量** + +这个顺序先提供恢复判断所需的高层信息,再提供完整证据下钻,最后展示资源使用。 + +## 4. 项目演进 + +“项目演进”只展示语义化里程碑,不直接展示每个消息或工具事件。合格节点包括: + +- 方案或约束被确认; +- 一个可识别的实施阶段完成; +- 关键验证或真实环境验收通过; +- 发布、回滚或重要版本变化; +- 重大失败、方向调整或阻断被解除。 + +默认可以只展示最近的里程碑,但必须同时显示总数和“查看全部”入口。完整模式使用搜索、分页或虚拟列表,不再用固定的 `slice` 伪装成完整项目史。 + +确定性 ProjectView 只能用中性文案生成有明确机器证据的里程碑,例如已创建提交、已通过验证或已发布版本。原因、意义和方向调整等语义只能来自 HumanPresentation 或经人确认的 AI 候选。界面对“机器验证”与“人工确认”里程碑显示不同来源标记。 + +项目首页中的原子事件 ID 列表被取消。事实索引通过“全部 Sessions”和私有 Observation Store 保留。 + +## 5. 决策与约定 + +“关键决策”更名为“决策与约定”,并分为两个区域。 + +### 5.1 已确认 + +正式条目只能来自: + +- 用户手动新增或编辑; +- 已有 v2/v3 决策迁移; +- AI 候选经用户确认。 + +每条记录包含: + +- 决策或约定内容; +- 理由; +- 影响范围; +- 状态:生效中、已替代、已归档; +- 重新评估条件; +- 关联的项目里程碑与 Sessions; +- 来源类型:人工创建、旧版迁移或 AI 候选确认。 + +默认只展示“生效中”条目。旧决策不物理删除,而是用“已被某决策替代”保留演进关系。 + +### 5.2 待确认候选 + +零 Token 扫描不自动生成决策候选。只有用户主动点击“从新 Sessions 提炼候选”时,系统才可调用受限 Agent: + +- 候选必须引用具体 Session 和已脱敏证据节点; +- 用户可编辑后确认、直接忽略或标记“不是决策”; +- 未确认候选不得进入项目演进,不得修改当前阶段、下一步或风险; +- 界面不展示不可校验的数字置信度,而是展示支持该候选的事实。 + +Agent 候选保存在私有、依赖绑定的语义注释存储中。Obsidian 插件通过受限只读查询加载候选;确认操作由受信 CLI 生成人类表现 patch 并进入既有同步流程。 + +无决策时不显示空白面板,而是显示: + +> 尚无已确认的决策与约定。扫描已经保存项目事实,但不会替你判断项目意图。 + +并提供“新增决策或约定”与“从新 Sessions 提炼候选”两个入口。项目首页最多展示三条当前生效的决策:人工置顶条目优先,其余按发生时间倒序和稳定 ID 排序。置顶是人类可编辑展示字段,不改变决策身份或证据。 + +## 6. 全部 Sessions + +### 6.1 列表完整性 + +每个被发现且可归属项目的 Session 必须占据一个索引项。损坏、未完成、部分可读、来源不可用或存在警告的 Session 不得从列表中消失。 + +列表顶部始终显示: + +- 已收录总数; +- 已完整处理数; +- 部分处理数; +- 异常数; +- 尚未处理数; +- 来源不可用数; +- 扫描时间范围与来源分布。 + +`complete`、`partial`、`error`、`unprocessed` 是互斥处理状态,四项之和必须等于已收录总数。`source_availability` 是与处理状态正交的维度;一个此前已完整处理、后来源文件消失的 Session 保留 `complete`,同时标记为 `source_unavailable`,不得因来源消失改写已经接受的处理结果。 + +处理状态固定定义为: + +- `complete`:已完成发现、冻结和解码,所有已知源记录均被访问,且没有无法解码、截断或未归类记录; +- `partial`:已产生可信 SessionView,但存在无法解码、未支持、折叠、截断或其他明确 coverage 缺口; +- `error`:本轮已尝试处理,但无法产生可信 SessionView; +- `unprocessed`:已经发现并归属项目,但在该扫描世代内尚未开始处理;只能出现在扫描中的暂态索引或 `completed_with_issues` 的终态记录中,必须带原因码。 + +现有机器终态到界面状态的默认映射为:`indexed` 且无 coverage 缺口映射到 `complete`;`indexed` 有缺口或 `unsupported` 但有可信部分投影映射到 `partial`;`unsupported`、`missing`、`unreadable`、`ambiguous` 且无可信投影映射到 `error`。实现不得仅凭警告数量猜测状态。 + +Session 索引是“项目已接受 Session 集合”的累积视图。新世代以旧索引和本轮发现集合的并集为输入:本轮未再次发现但过去已接受的 Session 保留索引、摘要和最后成功世代,并将来源标为 `source_unavailable`。扫描不会自动遗忘 Session;本设计不提供删除入口,未来若增加遗忘能力,必须是独立、明确确认并带审计记录的管理操作。 + +默认按时间倒序分组,稳定排序键为 `started_at desc nulls last, provider asc, session_id asc`。日期、来源、处理状态和来源可用性可直接由紧凑索引筛选;分支、文件和错误特征通过受限只读 CLI 查询,避免为搜索而把大量路径或错误文本复制到 Vault。 + +### 6.2 Session 索引项 + +每个索引项最少包含: + +- provider 与命名空间化 Session ID; +- 开始、结束时间与耗时; +- 处理状态、来源可用性与警告数; +- 总事件数和已索引事件数; +- 文件变更、命令、验证、错误和产物等类型统计; +- 关联用量索引; +- 覆盖数据,包括是否存在被折叠、未投影或无法解码的记录。 + +未知数据使用显式 `null` 和对应的 `*_known: false` 表示,不得用零代替未知。索引只保存项目相对路径的有限摘要、类型化错误码和不可逆错误签名,不保存绝对路径、错误原文、高熵字符串或完整 excerpt。 + +不在 Vault 中默认复制完整原始对话或工具输出。 + +### 6.3 下钻与分页 + +点击 Session 后,右侧详情展示该 Session 的阶段、关键操作、验证结果、错误和留下的问题。这些摘要必须是已脱敏且有来源绑定的确定性投影。 + +用户继续查看事件时,插件通过受限只读 CLI 命令从私有 Observation Store 分页获取数据。公开命令合同必须包含 project ID、provider、Session ID、expected generation ID、不透明 cursor 和受限 page size,且不得接受任意文件路径。 + +每页最多 100 条。界面显示“当前 1–100 / 共 2,438 条”等完整性信息。顺序浏览使用不透明 `previous_cursor` 和 `next_cursor`;跳转首页、末页或指定页时,插件把一基 ordinal 交给 CLI 换取当前世代的页锚点。cursor 必须绑定 project ID、provider、Session ID、generation ID、排序版本、筛选摘要和 page size。任一绑定不一致或世代过期时返回类型化 `stale_cursor`,插件刷新索引后回到最接近的可用位置,不静默读取另一个 Session 或世代。 + +### 6.4 投影文件 + +新增隐藏的: + +~~~text +docs/session-review/.session-reviewer/session-index.json +~~~ + +对应 Vault 投影仍位于 `.session-reviewer/` 下,不增加用户可见 Markdown 文档。 + +`session-index.json` 使用独立的 `session-index-v1` 合同,绑定 project ID、generation ID、ProjectView digest 和精确覆盖计数。它参与既有发布 journal、预像比较、Project/Vault 同步与发布后哈希验证。该文件是紧凑清单而不是 Session 详情库;最大 65,536 项、最大 64 MiB。超过任一上限时扫描不得截断后宣称完整,而应拒绝发布新世代并保留上一有效世代,报告 `session_index_capacity_exceeded`。 + +旧插件可忽略该隐藏文件;新插件在索引缺失时显示“需要重新扫描以建立完整 Session 索引”,不将缺失解释为零个 Sessions。 + +## 7. 用量与价格 + +### 7.1 展示 + +保留每个模型一张横向占满的卡片。卡片展示: + +- 模型与实际计费服务商; +- 输入、缓存输入、缓存写入、输出与总 Token; +- 每百万 Token 单价; +- 按类型估算成本与总成本; +- 价格有效日期与查询时间; +- 价格状态:当前、促销、已过期缓存、手动补充、存在歧义或待定; +- ModelPriceWatch 数据页与服务商官方价格页。 + +界面明确标注“公开 API 标价估算”,订阅包含量、实际账单折扣、税费与企业协议价不参与计算。 + +### 7.2 ModelPriceWatch 查询 + +默认使用 [ModelPriceWatch API](https://modelpricewatch.com/api/) 作为价格主查询目录。受信 CLI 对 `https://modelpricewatch.com/api/v1/models.json` 和 `https://modelpricewatch.com/api/v1/price-history.json` 分别做每 24 小时最多一次的全局缓存刷新,可使用 ETag 或等价条件请求。不按项目或模型频繁请求。 + +请求只下载公共价格目录,不上传项目名、Session ID、Token 计数、工作目录或其他本机数据。 + +匹配必须同时满足: + +1. 实际计费 provider/host 精确匹配到一个 ModelPriceWatch listing ID; +2. 规范模型 ID 或经审查的别名精确匹配; +3. 区域、调用模式、上下文档位、批处理状态和计费类型等适用条件均已知且相符; +4. 所有产生非零 Token 的计费维度都有对应明确价格。 + +不使用模糊名称相似度自动决定价格。同一模型在多个 host 出售时,必须使用 Session 实际路由的 host 价格。 + +ModelPriceWatch 记录作为查询索引,价格快照同时保存其 `detail_url`、服务商 `pricing_url`、数据 `last_updated`、本地 `retrieved_at`、促销标记和促销截止日期。界面按网站要求显示 ModelPriceWatch 归属链接。 + +ModelPriceWatch 的 `provider` 和模型名称不能脱离 listing ID 直接当作本机计费路由。受审查别名表使用 `(billing_host, billed_model_id, billing_mode, region) → modelpricewatch_listing_id` 作为键;同一键只能指向一个有效 listing,冲突时进入待定。 + +公开 API 未提供的计费维度,例如独立 cache-write 价格,必须由官方价格来源或有来源 URL 和生效日期的本地审核补充表提供。不自行推算缺失价格。`price_note` 等非结构化说明只作为审核提示,不由程序解析成计费规则;只要条目依赖尚未结构化支持的上下文档位、区域、批处理、促销或其他条件,就不得自动定价。 + +### 7.3 价格快照与降级 + +价格绑定到每个 Session 的用量记录,并作为不可变历史快照。价格目录更新只影响之后新接受的用量,不追溯重算旧 Session 成本。 + +历史 Session 在接受时没有价格的,允许日后补全,但只能使用在该 Session 计费时间已生效的可追溯历史价格,不得用查询时的当前价格倒填。历史查询选择 `price-history.json` 中不晚于计费时间的最近有效快照;若没有早于或等于计费时间的可验证基线,则保持待定。补全或纠错通过新的版本化快照和审计原因表示,不覆写原快照。 + +计费时间默认使用 Session `ended_at`。如果一个 Session 跨越价格变更边界,且用量不能按边界前后可靠拆分,则该 Session 进入 `ambiguous_billing_period`,不得用单一价格自动计算。 + +价格匹配优先级为: + +~~~text +ModelPriceWatch 的 provider + model 精确匹配 + → 官方来源可追溯快照 + → 有来源的本地已确认补充 + → 待定 +~~~ + +网络失败、限流或目录无匹配不得阻断 Session 扫描或投影。缓存年龄只按本地 `retrieved_at` 计算,ModelPriceWatch 的 `last_updated` 仅作为来源证据日期:不超过 24 小时的缓存是当前目录;超过 24 小时但不超过 7 天时可用作新 Session 的过期估算,但必须显示缓存年龄;超过 7 天的目录只能作为参考,不能创建新的已定价快照。 + +无可用价格或只有部分计费维度可定价时,逐维度显示缺失原因。数据合同保存 `known_subtotal_usd`、可空的 `total_cost_usd`、`pricing_complete` 和 `missing_billing_dimensions`;只有 `pricing_complete=true` 时才允许写入总成本。未知价格和未知成本均为 `null`,不得保存为数值零。 + +## 8. 端到端数据流 + +~~~text +Agent Session 来源 + → SourceAdapter 发现与解码 + → Observation Store(机器观察事实) + → SessionView(单 Session 确定性物化视图) + → ProjectView(项目级归并) + ├─→ 项目演进投影 + ├─→ session-index-v1 + ├─→ 用量记录 → 价格快照 + └─→ 受限只读事件查询 + +用户人工编辑 ──→ HumanPresentation ──→ 决策与约定 +受限 AI 提炼 ──→ AgentAnnotation ──→ 待确认候选 + └─→ 用户确认 → HumanPresentation +~~~ + +正式项目语义的展示优先级为: + +~~~text +HumanPresentation > 确定性 ProjectView +~~~ + +AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参与正式项目语义的优先级计算。用户确认后会创建 HumanPresentation 条目,不再以 AgentAnnotation 身份覆盖项目。该优先级只适用于人类语义与展示字段,不能改写 Session 身份、时间戳、Token 计数、命令退出码或来源哈希等机器事实。 + +## 9. 失败与恢复 + +各子系统独立失败,不相互放大: + +- Session 损坏:该 Session 记录为异常,其他 Sessions 继续处理。 +- 来源消失:继续展示已保留索引与摘要,深层事件标记为不可访问。 +- 列表或聚合超限:保留精确 coverage 计数,显示已展示、未展示和丢弃原因,不声称完整。 +- AI 候选失败:不改变扫描世代、正式决策或同步状态。 +- 价格查询失败:保留用量,使用已标注时效的缓存或进入待定状态。 +- 价格模型歧义:禁止模糊自动匹配,需要审核别名或人工补充。 +- Project/Vault 并发编辑:发布预像不一致时进入既有冲突处理,不覆盖人工内容。 +- 插件或 CLI 版本过旧:发布绑定 minimum writer/reader 能力,不将新投影静默降级成旧格式。 + +## 10. 迁移与兼容 + +- v2/v3 已有人工目标、状态、风险、决策和演进节点原样保留,不重新推断其语义。 +- 现有 v3 `recent-progress` 原子事件列表在下一次成功发布时从人类页面移除,对应观察事实仍在私有存储中可查。 +- `项目历史.md` 继续作为无插件时的语义里程碑降级入口,不扩展为全量原子事件库。 +- 新的 `session-index-v1` 使用独立隐藏合同,避免只因增加 Session 浏览能力就改写现有人类 Markdown 语义。 +- 不支持 Session 索引的旧插件在 v2/v3 数据保持未迁移时仍可解析项目回顾和历史;新插件对缺失索引的旧项目提供重新扫描入口。 +- 价格历史不因迁移或目录刷新被追溯重算。 +- 人类 Markdown 的决策字段扩展使用新的 presentation schema;迁移到 v4 后,旧插件属于不受支持的只读组合,不保证能解析新 schema,也不得写入。两个 Markdown 仍可由用户作为普通文档阅读;若需要在升级前获得明确的不兼容提示,应先发布能够识别 `minimum_reader_version` 的桥接版插件。 + +## 11. 验证策略 + +### 11.1 单元与合同测试 + +- Session 索引稳定排序、身份唯一性、世代绑定和 coverage 统计; +- 四种处理状态严格分区、未知值不伪装为零、来源消失后索引累积保留; +- Session 索引容量超限时保留上一有效世代并失败关闭; +- 异常、未完成、来源消失和部分可读 Session 不被过滤; +- cursor 分页的首页、中间页、末页、超限 page size 和身份混用失败关闭; +- 决策候选不能绕过用户确认进入 HumanPresentation; +- 相同 dependency 集合的提炼幂等、失败不推进 watermark、过期候选不能确认; +- 决策替代链无环且保留旧条目; +- provider + model 精确价格匹配、别名歧义、多 host 路由、促销、过期缓存和未匹配状态; +- 分档、区域、批处理、跨价格边界和缺少 cache-write 等条件不能被静默简化; +- 价格快照在后续目录变更后保持字节不变; +- 未定价成本不被纳入“完整总成本”。 + +### 11.2 集成与性能测试 + +- 单个 Session 含数千事件,可访问第一条、末条和中间页; +- 单项目含至少 154 个 Sessions,顶部总数与列表数量一致; +- 数万索引项使用虚拟列表,不一次创建全部 DOM 节点; +- Codex、Claude Code 和 OpenCode 混合项目按 namespaced identity 稳定归并,单一 provider 失败不影响其他来源; +- 网络断开、HTTP 429、超时和无匹配模型不影响扫描世代提交; +- Project/Vault 在扫描期间并发编辑时,预像检查拒绝覆盖人工修改; +- v2、现有 v3 和全新项目的迁移、重扫和恢复路径。 + +### 11.3 真实 Obsidian 验收 + +每次界面或合同修改后,都必须安装当前构建包到真实 Vault 并验证: + +1. 四个标签顺序正确; +2. 项目演进默认简洁且可打开全部里程碑; +3. Session 总数、异常数、列表和最后一个 Session 一致; +4. 超长 Session 分页无静默缺口; +5. 决策空状态、人工新增、AI 候选、确认和忽略流程正确; +6. 价格日期、数据页、官方来源、促销或待定状态可读; +7. 插件重启、Vault 重开和同步后状态不丢失; +8. 无 CLI、无网络和来源消失的降级提示准确。 + +无 CLI 时,“全部 Sessions”仍必须显示 `session-index-v1` 中的完整清单和基础筛选;仅 Session 摘要、深层事件和分支/文件/错误搜索被禁用,并给出安装或配置 CLI 的单一恢复入口。 + +## 12. 验收条件 + +交付必须同时满足: + +1. 用户可以在 Obsidian 中定位任何被扫描的 Session,包括最早、异常和来源不可用的 Session。 +2. 任何长列表都显示总数和当前范围,不存在无提示的固定截断。 +3. 项目首页和演进页不再显示大批原子消息 ID。 +4. 零 Token 扫描不创建或改写正式决策。 +5. AI 候选未经用户确认不得进入正式项目语义。 +6. 无决策时界面清楚解释扫描与语义确认的边界。 +7. 价格必须绑定实际 provider/host、模型、来源 URL 和日期;歧义或缺失不得自动猜测。 +8. 历史费用使用接受时价格快照,后续价格刷新不修改旧记录。 +9. 价格服务不可用时扫描仍成功,费用以“待定”降级而不是 `$0`。 +10. v2/v3 已有人工内容、决策替代关系和历史价格不因升级丢失或被重算。 +11. 已启用的 Codex、Claude Code 和 OpenCode Sessions 在同一四栏界面中具有相同的索引、下钻、状态和降级体验。 +12. Session 来源消失后,其已接受索引和摘要仍可见;恢复来源后,新世代能重新关联而不产生重复 Session。 + +## 13. 非目标 + +- 不把全部原始 Session 文本或完整工具输出复制到 Vault。 +- 不让零 Token 规则自动推断意图、理由或正式决策。 +- 不默认为每个 Session 调用 AI 生成摘要。 +- 不将 ModelPriceWatch 价格视为用户真实账单或不可复核的唯一真相。 +- 不在本设计中实现实际账单对账、订阅额度扣减或企业合同价。 +- 不增加用户可见的项目文档数量。 + +## 14. 建议实施边界 + +实施计划先完成合同与基线 Gate 0,再分成可独立验证的四组: + +0. 固定 0.3.5 v3 实施基线,落地 schema、CLI、状态机、版本矩阵和迁移夹具; + +1. `session-index-v1` 生成、发布、同步和只读分页查询; +2. Obsidian 四视图顺序、全部 Sessions 列表与超长 Session 下钻; +3. 决策与约定的空状态、人工新增、AI 候选与确认转换; +4. ModelPriceWatch 目录缓存、精确匹配、价格快照和用量卡片状态。 + +四组共享 Gate 0 固定的合同与世代身份,不得在 UI 实施过程中临时改变核心 schema。每组都必须在进入下一组前通过单元测试、集成测试和针对性真实 Obsidian 验收。本文件是总设计;四组分别生成实施计划,不合并成一个难以评审和回滚的巨型计划。 + +## 15. 实施基线与版本合同 + +### 15.1 基线 + +项目所有者在实施前确认使用远端最新发布标签 `0.3.5`(`ea5b1ba`)的零 Token v3 架构作为唯一实现基线,以保留 0.3.1–0.3.5 的扫描、Windows 和发布恢复修复。原工作区中的回退、删除或未完成跨版本修改保留原状;实现只在隔离分支 `codex/obsidian-context-v4` 中进行,不覆盖这些既有修改。 + +Gate 0 固定以下版本边界: + +- `review-presentation-v4`:两个可读 Markdown、扩展后的决策字段和对应 HumanPresentation patch; +- `machine-ledger-v4`:现有 v3 机器账本的后继版本,保存 presentation 基线、内嵌价格快照、当前快照引用和所有同步哈希; +- `session-index-v1`:完整 Session 紧凑索引; +- `session-summary-v1`:单 Session 的确定性、已脱敏详情响应; +- `session-event-page-v1`:Observation Store 的分页读取响应; +- `agent-annotation-v1`:私有候选决策与提炼运行状态; +- `pricing-snapshot-v1`:不可变价格快照,作为 `machine-ledger-v4` 的受校验成员。 +- `pricing-supplement-v1`:人工补价/纠错的受限标准输入合同;服务端计算费用,不持久化调用方提交的计算结果。 + +每个持久化合同必须同时提供 JSON Schema、Go 运行时校验、TypeScript 解析器、有效/无效 fixture 和规范化字节测试。新增字段不得只依赖 TypeScript 类型或 UI 判空。 + +### 15.2 Provider 范围 + +上述合同必须是 provider-neutral:`provider` 使用受限 safe ID,不在通用 schema 中写死为 `codex`。已启用的 Codex、Claude Code 和 OpenCode SourceAdapter 使用相同的 Session 索引、状态、分页和 Obsidian 表现合同;某个 provider 尚未安装或不兼容时,以来源级诊断呈现,不能让其他 provider 的 Session 消失。 + +本设计不重新定义三种 SourceAdapter 的解码细节。若实施基线尚未包含 Claude Code 或 OpenCode Adapter,它们是对应端到端验收的前置工作,不能通过在 UI 中显示 provider 名称冒充同等支持。 + +## 16. 持久化合同 + +### 16.1 `session-index-v1` + +顶层至少包含: + +~~~text +schema_version = 1 +minimum_reader_version +digest +project_id +generation_id +project_view_digest +generated_at +sort_version +coverage +sessions[] +~~~ + +`digest` 是对“省略 digest 字段后的规范 JSON 字节”计算的 SHA-256,避免自引用;数组顺序、空值和数字格式都进入规范化合同。 + +`coverage` 固定包含: + +~~~text +total +complete +partial +error +unprocessed +source_available +source_unavailable +started_at_known +ended_at_known +usage_known +~~~ + +强制不变量: + +~~~text +complete + partial + error + unprocessed = total +source_available + source_unavailable = total +len(sessions) = total +~~~ + +每个 `sessions[]` 索引项至少包含: + +~~~text +provider +session_id +processing_state +state_reason_codes[] +source_availability +source_terminal_state | null +started_at | null +ended_at | null +duration_ms | null +warning_count +record_count | null +indexed_event_count +coverage { seen, indexed, collapsed, unprojected, undecodable, truncated } +fact_counts { file_change, command, verification, error, artifact } +session_view_digest | null +usage_record_digest | null +summary_digest | null +last_seen_generation_id | null +last_successful_generation_id | null +~~~ + +索引身份是 `(project_id, provider, session_id)`。`session_id` 只需在同一 provider 内唯一,界面和所有 CLI 命令始终同时携带 provider。相同身份在新世代中形成新索引修订,不改写旧世代的规范字节。 + +所有数组有明确最大项数,所有字符串有 UTF-8 字节上限。`state_reason_codes` 只能使用版本化枚举;用户可见说明由插件本地化,机器文件中不保存任意错误文本。 + +### 16.2 Session 摘要 + +`session-summary-v1` 不写入 Vault,由 CLI 从当前 SessionView 和其依赖生成。它包含: + +- Session 身份、generation ID 和 SessionView digest; +- 阶段边界; +- 关键操作; +- 验证结果; +- 类型化错误; +- 未解决问题; +- 每个区块的 coverage 和来源 revision IDs。 + +每个区块最多 32 项,每项正文最多 512 UTF-8 字节,按 `occurred_at asc, sequence asc, revision_id asc` 稳定排序。超出部分保存总数和未展示数。摘要只能使用确定性规则和受限脱敏 excerpt;不得生成原因、意图或未被事实支持的“下一步”。规则 ID、规则版本和依赖摘要进入响应,以便重现。 + +### 16.3 文件所有权与发布 + +| 产物 | 权威写入方 | Project | Vault | 人工可编辑 | 发布事务 | +|---|---|---:|---:|---:|---:| +| `项目回顾.md` | HumanPresentation/同步引擎 | 是 | 是 | 是 | 是 | +| `项目历史.md` | HumanPresentation/同步引擎 | 是 | 是 | 是 | 是 | +| `.session-reviewer/ledger.json` | 受信 CLI | 是 | 是 | 否 | 是 | +| `.session-reviewer/session-index.json` | 扫描投影器 | 是 | 是 | 否 | 是 | +| Observation Store | 扫描引擎 | 私有 | 否 | 否 | 扫描世代事务 | +| AgentAnnotation Store | 决策候选服务 | 私有 | 否 | 否 | 独立 CAS | +| 全局价格目录缓存 | 价格服务 | 平台用户缓存 | 否 | 否 | 原子缓存刷新 | + +一次扫描发布的原子集合是两个 Markdown、`ledger.json` 和 `session-index.json`。journal 必须保存四者的目标哈希、预像哈希、临时文件和恢复阶段;任一写入、同步或发布后校验失败时,不能暴露混合世代。 + +人工编辑或候选确认的发布集合是两个 Markdown 和 `ledger.json`;事务开始前必须验证 `session-index.json` 仍绑定预期 generation,但无需重写其规范字节。普通 Project/Vault sync 比较和验证全部四个文件,机器文件仍只允许 Project 权威副本单向发布。 + +AgentAnnotation Store 和全局价格目录缓存不属于 Project/Vault 同步集合。候选确认会创建 HumanPresentation patch,随后才通过正常发布事务进入 Markdown 和 ledger。价格目录只是输入缓存;一旦价格被接受,`pricing-snapshot-v1` 作为 `machine-ledger-v4.pricing_snapshots[]` 成员随机器账本发布,之后不依赖缓存继续存在。 + +## 17. 状态机与 CLI 合同 + +### 17.1 Session 状态机 + +~~~text +discovered/unprocessed + ├─→ complete + ├─→ partial + └─→ error + +source_available ⇄ source_unavailable +~~~ + +处理状态是某个世代的结果,不在同一世代原地回退;重扫产生新世代。来源可用性可以在保留旧处理结果的前提下变化。失败或取消且未成功发布的扫描不改变当前有效索引。 + +### 17.2 决策候选状态机 + +候选状态固定为: + +~~~text +pending ─→ confirmed + ├─────→ ignored ─→ pending + ├─────→ not_decision + └─────→ stale +ignored ────────────→ stale +~~~ + +- `confirmed`、`not_decision` 和 `stale` 是该候选修订的终态; +- 确认后创建新的 HumanPresentation 决策,候选只保存其 `confirmed_decision_id`,不再参与正式展示优先级; +- 对正式决策的后续修改创建 HumanPresentation 新修订,不回写候选正文; +- `ignored` 默认隐藏但允许用户恢复; +- `not_decision` 持久保留,阻止相同提炼运行再次提出同一候选; +- 引用的 SessionView dependency 不再是活动修订时,未确认候选转为 `stale`,必须基于新依赖重新提炼后才能确认。 + +“新 Sessions”按 SessionView dependency digest 判断,不按文件修改时间判断。一次提炼运行的稳定身份由 `project_id + extractor_version + prompt_schema_version + 排序后的新 SessionView digests` 计算;同一身份重复点击返回原运行,不重复调用 Agent。只有成功保存候选后才推进 `last_successful_extraction_dependencies`;失败、取消或格式校验失败均不推进 watermark。 + +正式决策在 `review-presentation-v4` 中至少包含: + +~~~text +id +kind = decision | agreement +occurred_at +title +rationale +impact +status = active | superseded | archived +reevaluate_when +supersedes[] +milestone_ids[] +session_refs[] { provider, session_id } +provenance = human_created | migrated | ai_candidate_confirmed +pinned +revision +~~~ + +替代关系必须无环;`status=superseded` 时至少存在一个后继条目直接引用该条目,后继自身可以在以后继续被替代。迁移无法恢复的新增字段使用空值、空数组或 `false`,并保留 `provenance=migrated`,不得推断理由或关系。 + +### 17.3 只读 CLI + +插件只允许以 `shell=false` 和固定参数数组调用以下只读合同: + +~~~text +session-reviewer inspect session-summary + --project-id --provider --session-id + --expected-generation-id --json + +session-reviewer inspect session-events + --project-id --provider --session-id + --expected-generation-id + [--cursor | --anchor ] + --limit <1..100> --json + +session-reviewer inspect session-search + --project-id --expected-generation-id + --query-kind --query + [--cursor ] --limit <1..100> --json + +session-reviewer decisions candidates list + --project-id [--status ] --json +~~~ + +`session-search` 只返回匹配的 `(provider, session_id)`、命中类型、总数和分页 cursor;`query` 最大 256 UTF-8 字节,只参与规范化文本匹配,永远不作为文件系统路径解析。无 CLI 时插件禁用分支、文件和错误特征筛选,同时保留索引内的日期、来源和状态筛选。 + +`session-event-page-v1` 返回: + +~~~text +schema_version +project_id +provider +session_id +generation_id +session_view_digest +total +range_start +range_end +items[] +previous_cursor | null +next_cursor | null +first_cursor +last_cursor +coverage +~~~ + +当 `total=0` 时四个 cursor 均为 `null`,范围为 `0–0`;`--anchor` 小于 1 或大于 total 时返回 `anchor_out_of_range`,不得自动夹取到另一页。 + +事件项只包含类型化字段、有限脱敏 excerpt、revision ID、sequence 和 occurred_at。CLI 不返回原始系统/开发者指令、隐藏推理、令牌、绝对路径或未脱敏工具输出。cursor 最大长度、响应最大字节数和执行超时必须进入合同测试。 + +### 17.4 写入与异步 CLI + +写操作固定为: + +~~~text +session-reviewer decisions create + --project-id --expected-review-sha256 --json + +session-reviewer decisions extract + --project-id --expected-generation-id --json + +session-reviewer decisions extract status + --job-id --json + +session-reviewer decisions extract cancel + --job-id --expected-revision --json + +session-reviewer decisions candidate transition + --project-id --candidate-id + --expected-revision + --action + --expected-review-sha256 --json + +session-reviewer pricing supplement + --project-id --provider --session-id + --usage-record-digest + --expected-ledger-sha256 --json +~~~ + +`create`、带编辑内容的 `confirm` 和 `pricing supplement` 从标准输入读取最大 64 KiB 的版本化 JSON,不接受用户指定文件路径。补价输入使用 `pricing-supplement-v1`,必须完整声明计费路由、适用时间、可空费率、来源 URL、审计理由,以及可选的 `supersedes_snapshot_id`;服务端重新计算 billable quantities、line costs、subtotal 和 total,拒绝插件直接提交计算结果。`extract` 使用 SessionReviewer 已配置并验证的 proposal-only Agent,不接受 Markdown、候选正文或插件传入的任意可执行文件;它返回 job ID,状态查询复用现有受限异步任务模式。 + +所有写命令在修改前重新验证 project、generation、candidate revision 和 review 预像。CAS 失败返回当前摘要和类型化错误,不覆盖较新的扫描或人工编辑。任何候选或价格失败都不得推进扫描 generation。 + +### 17.5 价格状态机与快照 + +价格解析状态固定为: + +~~~text +pending +current +promotion +stale_estimate +manual_supplement +ambiguous +legacy_unverified +superseded +~~~ + +`pricing-snapshot-v1` 至少包含: + +~~~text +snapshot_id +project_id +provider +session_id +usage_record_digest +billing_host +billed_model_id +billing_mode +region | null +priced_at +created_at +status +modelpricewatch_listing_id | null +source_kind = modelpricewatch | official | manual | unresolved +source_url | null +detail_url | null +source_last_updated | null +retrieved_at | null +promo +promo_until | null +rates { input, cached_input, cache_write_input, output, reasoning_output } +billable_quantities { input, cached_input, cache_write_input, output, reasoning_output } +line_costs_usd { input, cached_input, cache_write_input, output, reasoning_output } +missing_billing_dimensions[] +known_subtotal_usd +total_cost_usd | null +pricing_complete +supersedes_snapshot_id | null +audit_reason +~~~ + +每个 rate 和 line cost 都是可空值;公开免费价格使用数值 `0`,未知使用 `null`。Token 原始计数不必天然互斥,因此每个 provider 的 UsageAdapter 必须显式生成互斥的 `billable_quantities`,并记录计费维度映射规则版本。例如 reasoning output 是否按 output 计费,只能由受审查的 provider 规则声明,不能跨 provider 默认套用。 + +两个价格目录响应分别设置 128 MiB 下载与解析上限,要求成功 HTTP 状态、JSON content type、受支持 schema、无重复字段和完整响应体;使用平台私有权限目录、进程锁和原子替换保存。刷新失败时保留上一份已验证缓存,不用半文件覆盖,也不把失败时间写成新的 `retrieved_at`。 + +目录刷新不修改快照。补价或纠错创建新快照,并通过 `supersedes_snapshot_id` 指向旧快照;聚合只选择每条用量的最新有效快照,但审计视图可以查看完整链。ModelPriceWatch、官方来源和人工补充的优先级不覆盖适用条件检查:任何条件不明都先进入 `pending` 或 `ambiguous`。 + +## 18. 兼容与迁移矩阵 + +| 项目数据 | CLI | 插件 | 行为 | +|---|---|---|---| +| v2 | 旧 | 旧 | 保持现有两文档体验,不出现新能力 | +| v2 | 新 | 新 | 先提供迁移 dry-run;成功原子迁移到 presentation/ledger v4 后启用完整能力 | +| v3 | 旧 | 新 | 可读现有回顾、历史和账本;新标签显示“CLI 版本过旧”,不显示零 Sessions 或零成本 | +| v3 | 新 | 旧 | 新 CLI 可继续只读和同步 v3;升级到 v4 必须显式执行迁移。迁移后旧插件不受支持,Markdown仍可人工阅读 | +| v3 | 新 | 新 | 先提供显式迁移 dry-run;确认后建立 session-index-v1 并原子升级到 v4,已有 HumanPresentation 原样保留 | +| v4 | 旧 | 任意 | 旧 CLI 检测 minimum writer/reader 后失败关闭,不写文件 | +| v4 | 新 | 旧 | 不受支持且必须保持只读;能识别 minimum reader 的桥接版显示不兼容提示,其他旧版只保证不会通过 CLI 写入 | +| v4 | 新 | 新 | 完整读写、扫描、候选确认、价格快照和同步能力 | + +v3 → v4 使用以下显式迁移合同: + +~~~text +session-reviewer sync --dry-run + [--project-id ] [--data-dir ] --json + +session-reviewer sync --confirm-migration + --expected-preview-digest + [--project-id ] [--data-dir ] --json +~~~ + +dry-run 返回版本化迁移预览、将保留或补默认值的语义单元、四文件目标哈希和 `preview_digest`,不写 Project、Vault 或发布指针。确认命令必须在锁内重新生成预览并核对 digest;任一源文件、SessionView dependency、当前 generation 或目标预像变化都返回 `migration_preview_stale`,不得套用旧预览。普通 `sync` 不得静默执行 v3 → v4 迁移。 + +迁移必须满足: + +1. dry-run 列出将新增、升级和保留的合同,不写文件; +2. v2/v3 决策的标题、理由、影响、状态、稳定 ID 和现有来源关系逐字节保留; +3. 新字段只填显式默认值,不由机器补写理由、重评条件或替代关系; +4. v3 `recent-progress` 仅在四文件新世代成功发布后从人类页面移除; +5. 旧价格保留为迁移快照,无法证明来源或日期时标为 `legacy_unverified`,不重算; +6. 迁移备份和 journal 遵循既有私有路径、原子替换和恢复规则; +7. 迁移后连续两次 render、sync 和重启不得产生字节、哈希或 revision 漂移。 + +Gate 0 完成标准是:上述所有 schema、状态枚举、CLI allowlist、兼容 fixture 和失败码均已固定并通过合同测试。只有此后才能进入四个功能实施计划。 From 5d605ef3a8592943a5079cb1e6c22975e9c516c2 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 12:08:22 +0800 Subject: [PATCH 02/25] feat: freeze project context v4 schemas --- .../task-1-report.md | 62 ++++ internal/memory/api_compat_test.go | 337 ++++++++++++++++++ schemas/agent-annotation-v1.schema.json | 28 ++ schemas/machine-ledger-v4.schema.json | 23 ++ schemas/pricing-snapshot-v1.schema.json | 51 +++ schemas/pricing-supplement-v1.schema.json | 32 ++ schemas/review-presentation-v4.schema.json | 26 ++ schemas/session-event-page-v1.schema.json | 34 ++ schemas/session-index-v1.schema.json | 70 ++++ schemas/session-summary-v1.schema.json | 40 +++ .../v4/agent-annotation-v1.invalid.json | 3 + .../v4/agent-annotation-v1.valid.json | 3 + .../v4/machine-ledger-v4.invalid.json | 2 + .../contracts/v4/machine-ledger-v4.valid.json | 5 + .../v4/pricing-snapshot-v1.invalid.json | 3 + .../v4/pricing-snapshot-v1.valid.json | 4 + .../v4/pricing-supplement-v1.invalid.json | 3 + .../v4/pricing-supplement-v1.valid.json | 3 + .../v4/review-presentation-v4.invalid.json | 5 + .../v4/review-presentation-v4.valid.json | 5 + .../v4/session-event-page-v1.invalid.json | 4 + .../v4/session-event-page-v1.valid.json | 4 + .../v4/session-index-v1.invalid.json | 13 + .../contracts/v4/session-index-v1.valid.json | 33 ++ .../v4/session-summary-v1.invalid.json | 5 + .../v4/session-summary-v1.valid.json | 5 + 26 files changed, 803 insertions(+) create mode 100644 .superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md create mode 100644 schemas/agent-annotation-v1.schema.json create mode 100644 schemas/machine-ledger-v4.schema.json create mode 100644 schemas/pricing-snapshot-v1.schema.json create mode 100644 schemas/pricing-supplement-v1.schema.json create mode 100644 schemas/review-presentation-v4.schema.json create mode 100644 schemas/session-event-page-v1.schema.json create mode 100644 schemas/session-index-v1.schema.json create mode 100644 schemas/session-summary-v1.schema.json create mode 100644 testdata/contracts/v4/agent-annotation-v1.invalid.json create mode 100644 testdata/contracts/v4/agent-annotation-v1.valid.json create mode 100644 testdata/contracts/v4/machine-ledger-v4.invalid.json create mode 100644 testdata/contracts/v4/machine-ledger-v4.valid.json create mode 100644 testdata/contracts/v4/pricing-snapshot-v1.invalid.json create mode 100644 testdata/contracts/v4/pricing-snapshot-v1.valid.json create mode 100644 testdata/contracts/v4/pricing-supplement-v1.invalid.json create mode 100644 testdata/contracts/v4/pricing-supplement-v1.valid.json create mode 100644 testdata/contracts/v4/review-presentation-v4.invalid.json create mode 100644 testdata/contracts/v4/review-presentation-v4.valid.json create mode 100644 testdata/contracts/v4/session-event-page-v1.invalid.json create mode 100644 testdata/contracts/v4/session-event-page-v1.valid.json create mode 100644 testdata/contracts/v4/session-index-v1.invalid.json create mode 100644 testdata/contracts/v4/session-index-v1.valid.json create mode 100644 testdata/contracts/v4/session-summary-v1.invalid.json create mode 100644 testdata/contracts/v4/session-summary-v1.valid.json diff --git a/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md new file mode 100644 index 0000000..3eb6d75 --- /dev/null +++ b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md @@ -0,0 +1,62 @@ +# Task 1 report: v4 contract schemas + +## Implementation summary + +Added the eight closed JSON Schema contracts and minimum valid/invalid fixtures for the v4 contract gate. The fixture test checks the supported schema keywords, rejects unknown fields, enforces the session-index coverage arithmetic at runtime, enforces the empty event-page cursor rule, and verifies every declared object schema closes `additionalProperties`. Generic provider IDs remain provider-neutral; digest fields use the `sha256:` form while explicit `*_sha256` fields use bare lowercase hex. + +## RED + +Command: + +```text +go test ./internal/memory -run TestV4ContractFixtures -count=1 +``` + +Output (before schemas and fixtures existed): + +```text +FAIL .../internal/memory [failed] +open ../../schemas/review-presentation-v4.schema.json: no such file or directory +``` + +## GREEN + +Commands and output: + +```text +gofmt -w internal/memory/api_compat_test.go +go test ./internal/memory -run TestV4ContractFixtures -count=1 +ok github.com/neomei/SessionReviewer/internal/memory 0.496s + +go test ./internal/memory -count=1 +ok github.com/neomei/SessionReviewer/internal/memory 0.507s + +go test -p 1 -timeout 5m ./... +ok github.com/neomei/SessionReviewer/test/zerotoken 42.272s + +go vet ./... +go mod tidy -diff +``` + +Both final commands completed with exit status 0 and no output. + +## Files changed + +- `schemas/review-presentation-v4.schema.json` +- `schemas/machine-ledger-v4.schema.json` +- `schemas/session-index-v1.schema.json` +- `schemas/session-summary-v1.schema.json` +- `schemas/session-event-page-v1.schema.json` +- `schemas/agent-annotation-v1.schema.json` +- `schemas/pricing-snapshot-v1.schema.json` +- `schemas/pricing-supplement-v1.schema.json` +- `testdata/contracts/v4/*` (16 fixtures) +- `internal/memory/api_compat_test.go` + +## Self-review + +Reviewed the complete diff, parsed all new schemas as JSON, checked `git diff --check`, and confirmed the fixture test covers all eight contract names and all declared object schemas are closed. + +## Concerns + +The Go runtime wire types and full duplicate-key/UTF-8/size validators are intentionally deferred to Task 2. The schema-only test uses a small test-local JSON-Schema subset because no schema-validation dependency is part of the 0.3.5 baseline. diff --git a/internal/memory/api_compat_test.go b/internal/memory/api_compat_test.go index daf8fa3..992a451 100644 --- a/internal/memory/api_compat_test.go +++ b/internal/memory/api_compat_test.go @@ -1,5 +1,16 @@ package memory +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "regexp" + "testing" +) + import "context" // These assignments are source-compatibility probes. A variadic parameter is @@ -31,3 +42,329 @@ var ( _ func(GenerationManifest) error = ValidateGenerationManifest _ func(context.Context, GenerationManifest) error = ValidateGenerationManifestContext ) + +// TestV4ContractFixtures is intentionally kept at the contract boundary. It +// exercises the JSON-Schema subset used by these wire contracts and checks +// the one cross-document invariant that JSON Schema cannot express: index +// coverage counts must reconcile with the entries array. +func TestV4ContractFixtures(t *testing.T) { + names := []string{"review-presentation-v4", "machine-ledger-v4", "session-index-v1", "session-summary-v1", "session-event-page-v1", "agent-annotation-v1", "pricing-snapshot-v1", "pricing-supplement-v1"} + for _, name := range names { + t.Run(name, func(t *testing.T) { + schema := readContractJSON(t, filepath.Join("..", "..", "schemas", name+".schema.json")) + if err := validateClosedSchemaObjects(schema, "$"); err != nil { + t.Fatalf("schema leaves an object boundary open: %v", err) + } + valid := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", name+".valid.json")) + invalid := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", name+".invalid.json")) + if err := validateContractSchema(schema, valid, "$", schema); err != nil { + t.Fatalf("valid fixture rejected: %v", err) + } + if name == "session-index-v1" { + // Arithmetic reconciliation is deliberately a runtime invariant, + // not a structural JSON-Schema keyword. + if err := validateSessionIndexCoverage(valid); err != nil { + t.Fatalf("valid coverage rejected: %v", err) + } + if err := validateSessionIndexCoverage(invalid); err == nil { + t.Fatal("invalid coverage accepted") + } + } else if name == "session-event-page-v1" { + if err := validateEventPageCursors(valid); err != nil { + t.Fatalf("valid cursors rejected: %v", err) + } + if err := validateEventPageCursors(invalid); err == nil { + t.Fatal("invalid empty-page cursors accepted") + } + } else if err := validateContractSchema(schema, invalid, "$", schema); err == nil { + t.Fatal("invalid fixture accepted") + } + }) + } +} + +func validateClosedSchemaObjects(value any, path string) error { + object, ok := value.(map[string]any) + if ok { + if object["type"] == "object" && object["additionalProperties"] != false { + return fmt.Errorf("%s: additionalProperties must be false", path) + } + for key, child := range object { + if key == "$schema" || key == "$id" || key == "title" || key == "$comment" { + continue + } + if err := validateClosedSchemaObjects(child, path+"."+key); err != nil { + return err + } + } + return nil + } + array, ok := value.([]any) + if ok { + for index, child := range array { + if err := validateClosedSchemaObjects(child, fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + } + return nil +} + +func validateEventPageCursors(value any) error { + root, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("root is not an object") + } + total, ok := root["total"].(json.Number) + if !ok { + return fmt.Errorf("total is not an integer") + } + if numberInt(total) != 0 { + return nil + } + for _, key := range []string{"previous_cursor", "next_cursor", "first_cursor", "last_cursor"} { + if root[key] != nil { + return fmt.Errorf("%s must be null for an empty page", key) + } + } + if root["range_start"].(json.Number) != "0" || root["range_end"].(json.Number) != "0" { + return fmt.Errorf("empty page range must be 0-0") + } + return nil +} + +func readContractJSON(t *testing.T, path string) any { + t.Helper() + body, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + dec := json.NewDecoder(bytes.NewReader(body)) + dec.UseNumber() + var value any + if err := dec.Decode(&value); err != nil { + t.Fatalf("decode %s: %v", path, err) + } + var trailing any + if err := dec.Decode(&trailing); err == nil { + t.Fatalf("%s contains trailing JSON", path) + } + return value +} + +func validateContractSchema(schema, value any, path string, root any) error { + s, ok := schema.(map[string]any) + if !ok { + return fmt.Errorf("%s: schema is not an object", path) + } + if ref, ok := s["$ref"].(string); ok { + const prefix = "#/$defs/" + if len(ref) < len(prefix) || ref[:len(prefix)] != prefix { + return fmt.Errorf("%s: unsupported ref %q", path, ref) + } + defs, ok := root.(map[string]any)["$defs"].(map[string]any) + if !ok { + return fmt.Errorf("%s: missing definitions", path) + } + target, ok := defs[ref[len(prefix):]] + if !ok { + return fmt.Errorf("%s: missing ref %q", path, ref) + } + return validateContractSchema(target, value, path, root) + } + if constValue, ok := s["const"]; ok && !reflect.DeepEqual(constValue, value) { + return fmt.Errorf("%s: want const %v", path, constValue) + } + if enum, ok := s["enum"].([]any); ok { + found := false + for _, candidate := range enum { + if reflect.DeepEqual(candidate, value) { + found = true + break + } + } + if !found { + return fmt.Errorf("%s: value is not in enum", path) + } + } + if types, ok := s["type"].([]any); ok { + matched := false + for _, typ := range types { + if schemaTypeMatches(typ.(string), value) { + matched = true + break + } + } + if !matched { + return fmt.Errorf("%s: wrong type", path) + } + } else if typ, ok := s["type"].(string); ok && !schemaTypeMatches(typ, value) { + return fmt.Errorf("%s: wrong type", path) + } + if pattern, ok := s["pattern"].(string); ok { + str, ok := value.(string) + if value == nil { + // Nullable fields may carry a format/pattern for their string arm. + } else if !ok || !regexp.MustCompile(pattern).MatchString(str) { + return fmt.Errorf("%s: pattern mismatch", path) + } + } + if min, ok := s["minLength"].(json.Number); ok { + str, isString := value.(string) + if isString && len([]byte(str)) < int(numberInt(min)) { + return fmt.Errorf("%s: too short", path) + } + } + if max, ok := s["maxLength"].(json.Number); ok { + str, isString := value.(string) + if isString && len([]byte(str)) > int(numberInt(max)) { + return fmt.Errorf("%s: too long", path) + } + } + if min, ok := s["minimum"].(json.Number); ok { + if numberFloat(value) < numberFloat(min) { + return fmt.Errorf("%s: below minimum", path) + } + } + if max, ok := s["maximum"].(json.Number); ok { + if numberFloat(value) > numberFloat(max) { + return fmt.Errorf("%s: above maximum", path) + } + } + if object, ok := value.(map[string]any); ok { + if required, ok := s["required"].([]any); ok { + for _, name := range required { + if _, exists := object[name.(string)]; !exists { + return fmt.Errorf("%s: missing %s", path, name) + } + } + } + properties, _ := s["properties"].(map[string]any) + if additional, ok := s["additionalProperties"].(bool); ok && !additional { + for name := range object { + if _, exists := properties[name]; !exists { + return fmt.Errorf("%s: unknown field %q", path, name) + } + } + } + for name, child := range properties { + if field, exists := object[name]; exists { + if err := validateContractSchema(child, field, path+"."+name, root); err != nil { + return err + } + } + } + } + if array, ok := value.([]any); ok { + if max, ok := s["maxItems"].(json.Number); ok && len(array) > int(numberInt(max)) { + return fmt.Errorf("%s: too many items", path) + } + if items, ok := s["items"]; ok { + for index, child := range array { + if err := validateContractSchema(items, child, fmt.Sprintf("%s[%d]", path, index), root); err != nil { + return err + } + } + } + } + return nil +} + +func schemaTypeMatches(typ string, value any) bool { + switch typ { + case "object": + _, ok := value.(map[string]any) + return ok + case "array": + _, ok := value.([]any) + return ok + case "string": + _, ok := value.(string) + return ok + case "integer", "number": + n, ok := value.(json.Number) + if !ok { + return false + } + if typ == "number" { + return true + } + return numberFloat(n) == float64(numberInt(n)) + case "null": + return value == nil + case "boolean": + _, ok := value.(bool) + return ok + default: + return false + } +} + +func numberInt(value json.Number) int64 { + n, _ := value.Int64() + return n +} + +func numberFloat(value any) float64 { + switch n := value.(type) { + case json.Number: + f, _ := n.Float64() + return f + default: + return 0 + } +} + +func validateSessionIndexCoverage(value any) error { + root, ok := value.(map[string]any) + if !ok { + return fmt.Errorf("root is not an object") + } + coverage, ok := root["coverage"].(map[string]any) + if !ok { + return fmt.Errorf("coverage is not an object") + } + sessions, ok := root["sessions"].([]any) + if !ok { + return fmt.Errorf("sessions is not an array") + } + get := func(key string) (int64, error) { + n, ok := coverage[key].(json.Number) + if !ok { + return 0, fmt.Errorf("coverage.%s is not an integer", key) + } + return numberInt(n), nil + } + total, err := get("total") + if err != nil { + return err + } + complete, err := get("complete") + if err != nil { + return err + } + partial, err := get("partial") + if err != nil { + return err + } + errCount, err := get("error") + if err != nil { + return err + } + unprocessed, err := get("unprocessed") + if err != nil { + return err + } + available, err := get("source_available") + if err != nil { + return err + } + unavailable, err := get("source_unavailable") + if err != nil { + return err + } + if complete+partial+errCount+unprocessed != total || available+unavailable != total || int64(len(sessions)) != total { + return fmt.Errorf("coverage counts do not reconcile") + } + return nil +} diff --git a/schemas/agent-annotation-v1.schema.json b/schemas/agent-annotation-v1.schema.json new file mode 100644 index 0000000..23cc417 --- /dev/null +++ b/schemas/agent-annotation-v1.schema.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/agent-annotation-v1.schema.json", + "title": "SessionReviewer agent annotation store v1", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "project_id", "annotations", "extraction_runs"], + "properties": { + "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, + "annotations": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/annotation" } }, + "extraction_runs": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/run" } } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "text": { "type": "string", "maxLength": 4096 }, "timestamp": { "type": "string", "maxLength": 128 }, + "annotation": { + "type": "object", "additionalProperties": false, + "required": ["id", "project_id", "entity_id", "field", "status", "text", "generation_id", "schema_version", "analysis_profile", "agent_run_id", "dependencies", "revision", "created_at", "confirmed_decision_id"], + "properties": { "id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "status": { "enum": ["pending", "confirmed", "ignored", "not_decision", "stale"] }, "text": { "$ref": "#/$defs/text" }, "generation_id": { "$ref": "#/$defs/id" }, "schema_version": { "const": 1 }, "analysis_profile": { "$ref": "#/$defs/id" }, "agent_run_id": { "$ref": "#/$defs/id" }, "dependencies": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/dependency" } }, "revision": { "type": "integer", "minimum": 1 }, "created_at": { "$ref": "#/$defs/timestamp" }, "confirmed_decision_id": { "type": ["string", "null"], "maxLength": 256 } } + }, + "dependency": { + "type": "object", "additionalProperties": false, "required": ["kind", "revision_id", "digest"], + "properties": { "kind": { "enum": ["observation", "session_view"] }, "revision_id": { "$ref": "#/$defs/id" }, "digest": { "$ref": "#/$defs/digest" } } + }, + "run": { + "type": "object", "additionalProperties": false, "required": ["run_id", "project_id", "status", "extractor_version", "prompt_schema_version", "dependency_digests", "created_at", "updated_at"], + "properties": { "run_id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, "status": { "enum": ["pending", "running", "completed", "failed", "cancelled"] }, "extractor_version": { "$ref": "#/$defs/id" }, "prompt_schema_version": { "$ref": "#/$defs/id" }, "dependency_digests": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/digest" } }, "created_at": { "$ref": "#/$defs/timestamp" }, "updated_at": { "$ref": "#/$defs/timestamp" } } + } + } +} diff --git a/schemas/machine-ledger-v4.schema.json b/schemas/machine-ledger-v4.schema.json new file mode 100644 index 0000000..d220426 --- /dev/null +++ b/schemas/machine-ledger-v4.schema.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/machine-ledger-v4.schema.json", + "title": "SessionReviewer machine ledger v4", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "accepted_revision", "review_sha256", "history_sha256", "accounting", "sessions", "human_patches", "orphan_patches", "generated_baselines", "pricing_snapshots", "current_pricing_snapshot_ids", "sync_hashes"], + "properties": { + "schema_version": { "const": 4 }, "minimum_reader_version": { "const": "0.4.0" }, "minimum_writer_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "accepted_revision": { "type": "integer", "minimum": 0 }, "review_sha256": { "$ref": "#/$defs/sha256" }, "history_sha256": { "$ref": "#/$defs/sha256" }, + "accounting": { "$ref": "#/$defs/accounting" }, "sessions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/session" } }, + "human_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "orphan_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "generated_baselines": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/baseline" } }, + "pricing_snapshots": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/snapshot" } }, "current_pricing_snapshot_ids": { "$ref": "#/$defs/id_array" }, "sync_hashes": { "$ref": "#/$defs/sync_hashes" } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "nonnegative": { "type": "integer", "minimum": 0 }, "id_array": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, + "accounting": { "type": "object", "additionalProperties": false, "required": ["total_duration_ms", "total_tokens", "total_cost_usd", "models"], "properties": { "total_duration_ms": { "$ref": "#/$defs/nonnegative" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": "number", "minimum": 0 }, "models": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/model" } } } }, + "model": { "type": "object", "additionalProperties": false, "required": ["model", "total_tokens", "total_cost_usd"], "properties": { "model": { "$ref": "#/$defs/text" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": "number", "minimum": 0 } } }, + "session": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "processing_state", "source_availability", "session_view_digest", "usage_record_digest"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "processing_state": { "enum": ["complete", "partial", "error", "unprocessed"] }, "source_availability": { "enum": ["available", "unavailable"] }, "session_view_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, "usage_record_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" } } }, + "patch": { "type": "object", "additionalProperties": false, "required": ["entity_id", "field", "operation", "base_generated_hash"], "properties": { "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "operation": { "enum": ["set", "suppress", "restore_default"] }, "value": { "$ref": "#/$defs/text" }, "values": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, "base_generated_hash": { "$ref": "#/$defs/sha256" } } }, + "baseline": { "type": "object", "additionalProperties": false, "required": ["generation_id", "entity_id", "field", "kind", "generated_hash"], "properties": { "generation_id": { "$ref": "#/$defs/id" }, "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "kind": { "$ref": "#/$defs/id" }, "value": { "$ref": "#/$defs/text" }, "values": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, "generated_hash": { "$ref": "#/$defs/sha256" } } }, + "snapshot": { "type": "object", "additionalProperties": false, "required": ["snapshot_id", "usage_record_digest", "status", "total_cost_usd", "pricing_complete"], "properties": { "snapshot_id": { "$ref": "#/$defs/id" }, "usage_record_digest": { "$ref": "#/$defs/digest" }, "status": { "enum": ["pending", "current", "promotion", "stale_estimate", "manual_supplement", "ambiguous", "legacy_unverified", "superseded"] }, "total_cost_usd": { "type": ["number", "null"], "minimum": 0 }, "pricing_complete": { "type": "boolean" } } }, + "sync_hashes": { "type": "object", "additionalProperties": false, "required": ["review_sha256", "history_sha256", "ledger_sha256", "session_index_digest"], "properties": { "review_sha256": { "$ref": "#/$defs/sha256" }, "history_sha256": { "$ref": "#/$defs/sha256" }, "ledger_sha256": { "$ref": "#/$defs/sha256" }, "session_index_digest": { "$ref": "#/$defs/digest" } } } + } +} diff --git a/schemas/pricing-snapshot-v1.schema.json b/schemas/pricing-snapshot-v1.schema.json new file mode 100644 index 0000000..37fc40a --- /dev/null +++ b/schemas/pricing-snapshot-v1.schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/pricing-snapshot-v1.schema.json", + "title": "SessionReviewer immutable pricing snapshot v1", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "snapshot_id", "project_id", "provider", "session_id", "usage_record_digest", "billing_host", "billed_model_id", "billing_mode", "region", "priced_at", "created_at", "status", "modelpricewatch_listing_id", "source_kind", "source_url", "detail_url", "source_last_updated", "retrieved_at", "promo", "promo_until", "rates", "billable_quantities", "line_costs_usd", "missing_billing_dimensions", "known_subtotal_usd", "total_cost_usd", "pricing_complete", "supersedes_snapshot_id", "audit_reason"], + "properties": { + "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, + "snapshot_id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, + "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, + "usage_record_digest": { "$ref": "#/$defs/digest" }, "billing_host": { "$ref": "#/$defs/text" }, + "billed_model_id": { "$ref": "#/$defs/text" }, "billing_mode": { "$ref": "#/$defs/text" }, + "region": { "type": ["string", "null"], "maxLength": 128 }, + "priced_at": { "$ref": "#/$defs/timestamp" }, "created_at": { "$ref": "#/$defs/timestamp" }, + "status": { "enum": ["pending", "current", "promotion", "stale_estimate", "manual_supplement", "ambiguous", "legacy_unverified", "superseded"] }, + "modelpricewatch_listing_id": { "type": ["string", "null"], "maxLength": 256 }, + "source_kind": { "enum": ["modelpricewatch", "official", "manual", "unresolved"] }, + "source_url": { "type": ["string", "null"], "maxLength": 2048 }, "detail_url": { "type": ["string", "null"], "maxLength": 2048 }, + "source_last_updated": { "$ref": "#/$defs/nullable_timestamp" }, "retrieved_at": { "$ref": "#/$defs/nullable_timestamp" }, + "promo": { "type": "boolean" }, "promo_until": { "$ref": "#/$defs/nullable_timestamp" }, + "rates": { "$ref": "#/$defs/rates" }, "billable_quantities": { "$ref": "#/$defs/quantities" }, "line_costs_usd": { "$ref": "#/$defs/line_costs" }, + "missing_billing_dimensions": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/text" } }, + "known_subtotal_usd": { "type": "number", "minimum": 0 }, "total_cost_usd": { "type": ["number", "null"], "minimum": 0 }, + "pricing_complete": { "type": "boolean" }, "supersedes_snapshot_id": { "type": ["string", "null"], "maxLength": 256 }, + "audit_reason": { "$ref": "#/$defs/text" } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "text": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, + "nullable_timestamp": { "type": ["string", "null"], "maxLength": 128 }, + "nullable_money": { "type": ["number", "null"], "minimum": 0 }, + "rates": { + "type": "object", "additionalProperties": false, + "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], + "properties": { "input": { "$ref": "#/$defs/nullable_money" }, "cached_input": { "$ref": "#/$defs/nullable_money" }, "cache_write_input": { "$ref": "#/$defs/nullable_money" }, "output": { "$ref": "#/$defs/nullable_money" }, "reasoning_output": { "$ref": "#/$defs/nullable_money" } } + }, + "quantities": { + "type": "object", "additionalProperties": false, + "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], + "properties": { "input": { "$ref": "#/$defs/nonnegative" }, "cached_input": { "$ref": "#/$defs/nonnegative" }, "cache_write_input": { "$ref": "#/$defs/nonnegative" }, "output": { "$ref": "#/$defs/nonnegative" }, "reasoning_output": { "$ref": "#/$defs/nonnegative" } } + }, + "line_costs": { + "type": "object", "additionalProperties": false, + "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], + "properties": { "input": { "$ref": "#/$defs/nullable_money" }, "cached_input": { "$ref": "#/$defs/nullable_money" }, "cache_write_input": { "$ref": "#/$defs/nullable_money" }, "output": { "$ref": "#/$defs/nullable_money" }, "reasoning_output": { "$ref": "#/$defs/nullable_money" } } + }, + "nonnegative": { "type": "integer", "minimum": 0 } + } +} diff --git a/schemas/pricing-supplement-v1.schema.json b/schemas/pricing-supplement-v1.schema.json new file mode 100644 index 0000000..b6ef308 --- /dev/null +++ b/schemas/pricing-supplement-v1.schema.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/pricing-supplement-v1.schema.json", + "title": "SessionReviewer pricing supplement input v1", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "usage_record_digest", "billing_host", "billed_model_id", "billing_mode", "region", "effective_from", "effective_until", "rates", "source_url", "detail_url", "audit_reason", "supersedes_snapshot_id"], + "properties": { + "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, + "project_id": { "$ref": "#/$defs/id" }, "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, + "usage_record_digest": { "$ref": "#/$defs/digest" }, "billing_host": { "$ref": "#/$defs/text" }, + "billed_model_id": { "$ref": "#/$defs/text" }, "billing_mode": { "$ref": "#/$defs/text" }, + "region": { "type": ["string", "null"], "maxLength": 128 }, + "effective_from": { "$ref": "#/$defs/timestamp" }, "effective_until": { "$ref": "#/$defs/nullable_timestamp" }, + "rates": { "$ref": "#/$defs/rates" }, "source_url": { "$ref": "#/$defs/url" }, + "detail_url": { "type": ["string", "null"], "maxLength": 2048 }, "audit_reason": { "$ref": "#/$defs/text" }, + "supersedes_snapshot_id": { "type": ["string", "null"], "maxLength": 256 } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "text": { "type": "string", "minLength": 1, "maxLength": 4096 }, + "url": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^https?://[^\\s]+$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, + "nullable_timestamp": { "type": ["string", "null"], "maxLength": 128 }, + "nullable_money": { "type": ["number", "null"], "minimum": 0 }, + "rates": { + "type": "object", "additionalProperties": false, + "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], + "properties": { "input": { "$ref": "#/$defs/nullable_money" }, "cached_input": { "$ref": "#/$defs/nullable_money" }, "cache_write_input": { "$ref": "#/$defs/nullable_money" }, "output": { "$ref": "#/$defs/nullable_money" }, "reasoning_output": { "$ref": "#/$defs/nullable_money" } } + } + } +} diff --git a/schemas/review-presentation-v4.schema.json b/schemas/review-presentation-v4.schema.json new file mode 100644 index 0000000..cf7d8cb --- /dev/null +++ b/schemas/review-presentation-v4.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/review-presentation-v4.schema.json", + "title": "SessionReviewer human review presentation v4", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "revision", "current_state", "timeline", "decisions", "risks", "open_loops", "human_patches", "orphan_patches", "generated_baselines"], + "properties": { + "schema_version": { "const": 4 }, "minimum_reader_version": { "const": "0.4.0" }, "minimum_writer_version": { "const": "0.4.0" }, + "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "revision": { "type": "integer", "minimum": 0 }, + "current_state": { "$ref": "#/$defs/current_state" }, "timeline": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/timeline" } }, + "decisions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/decision" } }, "risks": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/risk" } }, "open_loops": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/open_loop" } }, + "human_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "orphan_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "generated_baselines": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/baseline" } } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "strings": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, + "current_state": { "type": "object", "additionalProperties": false, "required": ["goal", "stage", "status", "next_action", "last_verification"], "properties": { "goal": { "$ref": "#/$defs/text" }, "stage": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "next_action": { "$ref": "#/$defs/text" }, "last_verification": { "$ref": "#/$defs/text" } } }, + "timeline": { "type": "object", "additionalProperties": false, "required": ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids"], "properties": { "id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "kind": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "summary": { "$ref": "#/$defs/text" }, "decision_ids": { "$ref": "#/$defs/id_array" } } }, + "decision": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "occurred_at", "title", "rationale", "impact", "status", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "kind": { "enum": ["decision", "agreement"] }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "title": { "$ref": "#/$defs/text" }, "rationale": { "$ref": "#/$defs/text" }, "impact": { "$ref": "#/$defs/text" }, "status": { "enum": ["active", "superseded", "archived"] }, "reevaluate_when": { "$ref": "#/$defs/text" }, "supersedes": { "$ref": "#/$defs/id_array" }, "milestone_ids": { "$ref": "#/$defs/id_array" }, "session_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/session_ref" } }, "provenance": { "enum": ["human_created", "migrated", "ai_candidate_confirmed"] }, "pinned": { "type": "boolean" }, "revision": { "type": "integer", "minimum": 1 } } }, + "session_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" } } }, + "risk": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "detail"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "detail": { "$ref": "#/$defs/text" } } }, + "open_loop": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "question", "next_experiment", "completion_criterion"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "question": { "$ref": "#/$defs/text" }, "next_experiment": { "$ref": "#/$defs/text" }, "completion_criterion": { "$ref": "#/$defs/text" } } }, + "patch": { "type": "object", "additionalProperties": false, "required": ["entity_id", "field", "operation", "base_generated_hash"], "properties": { "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "operation": { "enum": ["set", "suppress", "restore_default"] }, "value": { "$ref": "#/$defs/text" }, "values": { "$ref": "#/$defs/strings" }, "base_generated_hash": { "$ref": "#/$defs/sha256" } } }, + "baseline": { "type": "object", "additionalProperties": false, "required": ["generation_id", "entity_id", "field", "kind", "generated_hash"], "properties": { "generation_id": { "$ref": "#/$defs/id" }, "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "kind": { "$ref": "#/$defs/id" }, "value": { "$ref": "#/$defs/text" }, "values": { "$ref": "#/$defs/strings" }, "generated_hash": { "$ref": "#/$defs/sha256" } } }, + "id_array": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/id" } } + } +} diff --git a/schemas/session-event-page-v1.schema.json b/schemas/session-event-page-v1.schema.json new file mode 100644 index 0000000..9c9bdad --- /dev/null +++ b/schemas/session-event-page-v1.schema.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/session-event-page-v1.schema.json", + "title": "SessionReviewer event page response v1", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "generation_id", "session_view_digest", "total", "range_start", "range_end", "items", "previous_cursor", "next_cursor", "first_cursor", "last_cursor", "coverage"], + "properties": { + "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, + "project_id": { "$ref": "#/$defs/id" }, "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, + "session_view_digest": { "$ref": "#/$defs/digest" }, "total": { "$ref": "#/$defs/nonnegative" }, + "range_start": { "$ref": "#/$defs/nonnegative" }, "range_end": { "$ref": "#/$defs/nonnegative" }, + "items": { "type": "array", "maxItems": 100, "items": { "$ref": "#/$defs/item" } }, + "previous_cursor": { "$ref": "#/$defs/cursor" }, "next_cursor": { "$ref": "#/$defs/cursor" }, + "first_cursor": { "$ref": "#/$defs/cursor" }, "last_cursor": { "$ref": "#/$defs/cursor" }, + "coverage": { "$ref": "#/$defs/coverage" } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "nonnegative": { "type": "integer", "minimum": 0 }, + "cursor": { "type": ["string", "null"], "maxLength": 4096 }, + "timestamp": { "type": "string", "maxLength": 128 }, + "item": { + "type": "object", "additionalProperties": false, + "required": ["kind", "excerpt", "revision_id", "sequence", "occurred_at"], + "properties": { "kind": { "enum": ["message", "tool_call", "tool_result", "cwd_change", "usage", "skip", "file_change", "command", "verification", "error", "artifact"] }, "excerpt": { "type": "string", "maxLength": 512 }, "revision_id": { "$ref": "#/$defs/id" }, "sequence": { "type": "integer", "minimum": 1 }, "occurred_at": { "$ref": "#/$defs/timestamp" } } + }, + "coverage": { + "type": "object", "additionalProperties": false, + "required": ["seen", "indexed", "collapsed", "unprojected", "undecodable", "truncated"], + "properties": { "seen": { "$ref": "#/$defs/nonnegative" }, "indexed": { "$ref": "#/$defs/nonnegative" }, "collapsed": { "$ref": "#/$defs/nonnegative" }, "unprojected": { "$ref": "#/$defs/nonnegative" }, "undecodable": { "$ref": "#/$defs/nonnegative" }, "truncated": { "$ref": "#/$defs/nonnegative" } } + } + } +} diff --git a/schemas/session-index-v1.schema.json b/schemas/session-index-v1.schema.json new file mode 100644 index 0000000..f135efd --- /dev/null +++ b/schemas/session-index-v1.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/session-index-v1.schema.json", + "title": "SessionReviewer complete session index v1", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "digest", "project_id", "generation_id", "project_view_digest", "generated_at", "sort_version", "coverage", "sessions"], + "properties": { + "schema_version": { "const": 1 }, + "minimum_reader_version": { "const": "0.4.0" }, + "minimum_writer_version": { "const": "0.4.0" }, + "digest": { "$ref": "#/$defs/digest" }, + "project_id": { "$ref": "#/$defs/id" }, + "generation_id": { "$ref": "#/$defs/id" }, + "project_view_digest": { "$ref": "#/$defs/digest" }, + "generated_at": { "$ref": "#/$defs/timestamp" }, + "sort_version": { "const": "started-at-desc-null-last-provider-session-v1" }, + "coverage": { "$ref": "#/$defs/index_coverage" }, + "sessions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/session" } } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "timestamp": { "type": ["string", "null"], "maxLength": 128 }, + "nonnegative": { "type": "integer", "minimum": 0 }, + "id_array": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/id" } }, + "index_coverage": { + "type": "object", "additionalProperties": false, + "required": ["total", "complete", "partial", "error", "unprocessed", "source_available", "source_unavailable", "started_at_known", "ended_at_known", "usage_known"], + "properties": { + "total": { "$ref": "#/$defs/nonnegative" }, "complete": { "$ref": "#/$defs/nonnegative" }, + "partial": { "$ref": "#/$defs/nonnegative" }, "error": { "$ref": "#/$defs/nonnegative" }, + "unprocessed": { "$ref": "#/$defs/nonnegative" }, "source_available": { "$ref": "#/$defs/nonnegative" }, + "source_unavailable": { "$ref": "#/$defs/nonnegative" }, "started_at_known": { "$ref": "#/$defs/nonnegative" }, + "ended_at_known": { "$ref": "#/$defs/nonnegative" }, "usage_known": { "$ref": "#/$defs/nonnegative" } + } + }, + "session": { + "type": "object", "additionalProperties": false, + "required": ["provider", "session_id", "processing_state", "state_reason_codes", "source_availability", "source_terminal_state", "started_at", "ended_at", "duration_ms", "warning_count", "record_count", "indexed_event_count", "coverage", "fact_counts", "session_view_digest", "usage_record_digest", "summary_digest", "last_seen_generation_id", "last_successful_generation_id"], + "properties": { + "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, + "processing_state": { "enum": ["complete", "partial", "error", "unprocessed"] }, + "state_reason_codes": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } }, + "source_availability": { "enum": ["available", "unavailable"] }, + "source_terminal_state": { "type": ["string", "null"], "maxLength": 64 }, + "started_at": { "$ref": "#/$defs/timestamp" }, "ended_at": { "$ref": "#/$defs/timestamp" }, + "duration_ms": { "type": ["integer", "null"], "minimum": 0 }, "warning_count": { "$ref": "#/$defs/nonnegative" }, + "record_count": { "type": ["integer", "null"], "minimum": 0 }, "indexed_event_count": { "$ref": "#/$defs/nonnegative" }, + "coverage": { "$ref": "#/$defs/session_coverage" }, "fact_counts": { "$ref": "#/$defs/fact_counts" }, + "session_view_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, + "usage_record_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, + "summary_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, + "last_seen_generation_id": { "type": ["string", "null"], "maxLength": 256 }, + "last_successful_generation_id": { "type": ["string", "null"], "maxLength": 256 } + } + }, + "session_coverage": { + "type": "object", "additionalProperties": false, + "required": ["seen", "indexed", "collapsed", "unprojected", "undecodable", "truncated"], + "properties": { "seen": { "$ref": "#/$defs/nonnegative" }, "indexed": { "$ref": "#/$defs/nonnegative" }, "collapsed": { "$ref": "#/$defs/nonnegative" }, "unprojected": { "$ref": "#/$defs/nonnegative" }, "undecodable": { "$ref": "#/$defs/nonnegative" }, "truncated": { "$ref": "#/$defs/nonnegative" } } + }, + "fact_counts": { + "type": "object", "additionalProperties": false, + "required": ["file_change", "command", "verification", "error", "artifact"], + "properties": { "file_change": { "$ref": "#/$defs/nonnegative" }, "command": { "$ref": "#/$defs/nonnegative" }, "verification": { "$ref": "#/$defs/nonnegative" }, "error": { "$ref": "#/$defs/nonnegative" }, "artifact": { "$ref": "#/$defs/nonnegative" } } + } + } +} diff --git a/schemas/session-summary-v1.schema.json b/schemas/session-summary-v1.schema.json new file mode 100644 index 0000000..c8f29c1 --- /dev/null +++ b/schemas/session-summary-v1.schema.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/session-summary-v1.schema.json", + "title": "SessionReviewer deterministic session summary v1", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "generation_id", "session_view_digest", "phase_boundaries", "key_operations", "verification_results", "errors", "unresolved_questions", "rules", "coverage"], + "properties": { + "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, + "project_id": { "$ref": "#/$defs/id" }, "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "session_view_digest": { "$ref": "#/$defs/digest" }, + "phase_boundaries": { "$ref": "#/$defs/block" }, "key_operations": { "$ref": "#/$defs/block" }, "verification_results": { "$ref": "#/$defs/block" }, "errors": { "$ref": "#/$defs/block" }, "unresolved_questions": { "$ref": "#/$defs/block" }, + "rules": { "$ref": "#/$defs/rules" }, "coverage": { "$ref": "#/$defs/coverage" } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "text": { "type": "string", "maxLength": 512 }, "nonnegative": { "type": "integer", "minimum": 0 }, + "timestamp": { "type": "string", "maxLength": 128 }, + "block": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/entry" } }, + "entry": { + "type": "object", "additionalProperties": false, + "required": ["occurred_at", "sequence", "revision_id", "text", "coverage", "source_revision_ids"], + "properties": { "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "coverage": { "$ref": "#/$defs/entry_coverage" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } + }, + "entry_coverage": { + "type": "object", "additionalProperties": false, + "required": ["total", "shown", "omitted"], + "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" } } + }, + "rules": { + "type": "object", "additionalProperties": false, + "required": ["rule_id", "rule_version", "dependency_digests"], + "properties": { "rule_id": { "$ref": "#/$defs/id" }, "rule_version": { "$ref": "#/$defs/id" }, "dependency_digests": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/digest" } } } + }, + "coverage": { + "type": "object", "additionalProperties": false, + "required": ["total", "shown", "omitted"], + "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" } } + } + } +} diff --git a/testdata/contracts/v4/agent-annotation-v1.invalid.json b/testdata/contracts/v4/agent-annotation-v1.invalid.json new file mode 100644 index 0000000..47facab --- /dev/null +++ b/testdata/contracts/v4/agent-annotation-v1.invalid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "annotations": [], "extraction_runs": [], "unknown": true +} diff --git a/testdata/contracts/v4/agent-annotation-v1.valid.json b/testdata/contracts/v4/agent-annotation-v1.valid.json new file mode 100644 index 0000000..9735f12 --- /dev/null +++ b/testdata/contracts/v4/agent-annotation-v1.valid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "annotations": [], "extraction_runs": [] +} diff --git a/testdata/contracts/v4/machine-ledger-v4.invalid.json b/testdata/contracts/v4/machine-ledger-v4.invalid.json new file mode 100644 index 0000000..f1cff83 --- /dev/null +++ b/testdata/contracts/v4/machine-ledger-v4.invalid.json @@ -0,0 +1,2 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "accepted_revision": 0, "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": 0, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [], "current_pricing_snapshot_ids": [], "sync_hashes": { "review_sha256": "bad", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "ledger_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "session_index_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } } diff --git a/testdata/contracts/v4/machine-ledger-v4.valid.json b/testdata/contracts/v4/machine-ledger-v4.valid.json new file mode 100644 index 0000000..27f44df --- /dev/null +++ b/testdata/contracts/v4/machine-ledger-v4.valid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "accepted_revision": 0, "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": 0, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [], "current_pricing_snapshot_ids": [], + "sync_hashes": { "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "ledger_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "session_index_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } +} diff --git a/testdata/contracts/v4/pricing-snapshot-v1.invalid.json b/testdata/contracts/v4/pricing-snapshot-v1.invalid.json new file mode 100644 index 0000000..b3f88cb --- /dev/null +++ b/testdata/contracts/v4/pricing-snapshot-v1.invalid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "not-a-price-state", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0, "total_cost_usd": null, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Incomplete" +} diff --git a/testdata/contracts/v4/pricing-snapshot-v1.valid.json b/testdata/contracts/v4/pricing-snapshot-v1.valid.json new file mode 100644 index 0000000..c1fc3ae --- /dev/null +++ b/testdata/contracts/v4/pricing-snapshot-v1.valid.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, + "rates": { "input": 1.0, "cached_input": 0, "cache_write_input": null, "output": 2.0, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0.00001, "cached_input": 0, "cache_write_input": null, "output": 0.00001, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0.00002, "total_cost_usd": 0.00002, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Official price matched exact billing route." +} diff --git a/testdata/contracts/v4/pricing-supplement-v1.invalid.json b/testdata/contracts/v4/pricing-supplement-v1.invalid.json new file mode 100644 index 0000000..a694b7e --- /dev/null +++ b/testdata/contracts/v4/pricing-supplement-v1.invalid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "not-a-url", "detail_url": null, "audit_reason": "Invalid source.", "supersedes_snapshot_id": null +} diff --git a/testdata/contracts/v4/pricing-supplement-v1.valid.json b/testdata/contracts/v4/pricing-supplement-v1.valid.json new file mode 100644 index 0000000..34df204 --- /dev/null +++ b/testdata/contracts/v4/pricing-supplement-v1.valid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "https://example.test/pricing", "detail_url": null, "audit_reason": "Public pricing page confirms free route.", "supersedes_snapshot_id": null +} diff --git a/testdata/contracts/v4/review-presentation-v4.invalid.json b/testdata/contracts/v4/review-presentation-v4.invalid.json new file mode 100644 index 0000000..b5fad62 --- /dev/null +++ b/testdata/contracts/v4/review-presentation-v4.invalid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, + "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04", "unknown": true }, + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] +} diff --git a/testdata/contracts/v4/review-presentation-v4.valid.json b/testdata/contracts/v4/review-presentation-v4.valid.json new file mode 100644 index 0000000..feb0a10 --- /dev/null +++ b/testdata/contracts/v4/review-presentation-v4.valid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, + "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04" }, + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] +} diff --git a/testdata/contracts/v4/session-event-page-v1.invalid.json b/testdata/contracts/v4/session-event-page-v1.invalid.json new file mode 100644 index 0000000..d0fc5d1 --- /dev/null +++ b/testdata/contracts/v4/session-event-page-v1.invalid.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "claude", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "total": 0, "range_start": 0, "range_end": 0, "items": [], "previous_cursor": "cursor", "next_cursor": null, "first_cursor": null, "last_cursor": null, + "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 } +} diff --git a/testdata/contracts/v4/session-event-page-v1.valid.json b/testdata/contracts/v4/session-event-page-v1.valid.json new file mode 100644 index 0000000..7c86352 --- /dev/null +++ b/testdata/contracts/v4/session-event-page-v1.valid.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "claude", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "total": 0, "range_start": 0, "range_end": 0, "items": [], "previous_cursor": null, "next_cursor": null, "first_cursor": null, "last_cursor": null, + "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 } +} diff --git a/testdata/contracts/v4/session-index-v1.invalid.json b/testdata/contracts/v4/session-index-v1.invalid.json new file mode 100644 index 0000000..ac8e20b --- /dev/null +++ b/testdata/contracts/v4/session-index-v1.invalid.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "minimum_reader_version": "0.4.0", + "minimum_writer_version": "0.4.0", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "project_id": "project-p", + "generation_id": "generation-1", + "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "generated_at": "2026-09-04T00:00:00Z", + "sort_version": "started-at-desc-null-last-provider-session-v1", + "coverage": { "total": 2, "complete": 1, "partial": 0, "error": 0, "unprocessed": 0, "source_available": 1, "source_unavailable": 0, "started_at_known": 1, "ended_at_known": 1, "usage_known": 0 }, + "sessions": [] +} diff --git a/testdata/contracts/v4/session-index-v1.valid.json b/testdata/contracts/v4/session-index-v1.valid.json new file mode 100644 index 0000000..b033108 --- /dev/null +++ b/testdata/contracts/v4/session-index-v1.valid.json @@ -0,0 +1,33 @@ +{ + "schema_version": 1, + "minimum_reader_version": "0.4.0", + "minimum_writer_version": "0.4.0", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "project_id": "project-p", + "generation_id": "generation-1", + "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "generated_at": "2026-09-04T00:00:00Z", + "sort_version": "started-at-desc-null-last-provider-session-v1", + "coverage": { "total": 1, "complete": 1, "partial": 0, "error": 0, "unprocessed": 0, "source_available": 1, "source_unavailable": 0, "started_at_known": 1, "ended_at_known": 1, "usage_known": 0 }, + "sessions": [{ + "provider": "claude", + "session_id": "session-1", + "processing_state": "complete", + "state_reason_codes": [], + "source_availability": "available", + "source_terminal_state": null, + "started_at": "2026-09-04T00:00:00Z", + "ended_at": "2026-09-04T00:01:00Z", + "duration_ms": 60000, + "warning_count": 0, + "record_count": 1, + "indexed_event_count": 1, + "coverage": { "seen": 1, "indexed": 1, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, + "fact_counts": { "file_change": 0, "command": 0, "verification": 0, "error": 0, "artifact": 0 }, + "session_view_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "usage_record_digest": null, + "summary_digest": null, + "last_seen_generation_id": "generation-1", + "last_successful_generation_id": "generation-1" + }] +} diff --git a/testdata/contracts/v4/session-summary-v1.invalid.json b/testdata/contracts/v4/session-summary-v1.invalid.json new file mode 100644 index 0000000..9420d19 --- /dev/null +++ b/testdata/contracts/v4/session-summary-v1.invalid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "opencode", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "phase_boundaries": [], "key_operations": [], "verification_results": [], "errors": [], "unresolved_questions": [], + "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "total": 0, "shown": 0, "omitted": 0 }, "unknown": true +} diff --git a/testdata/contracts/v4/session-summary-v1.valid.json b/testdata/contracts/v4/session-summary-v1.valid.json new file mode 100644 index 0000000..59bfd60 --- /dev/null +++ b/testdata/contracts/v4/session-summary-v1.valid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "opencode", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "phase_boundaries": [], "key_operations": [], "verification_results": [], "errors": [], "unresolved_questions": [], + "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "total": 0, "shown": 0, "omitted": 0 } +} From 2a7ff3440d4ed1edafc1e252f1025b2d9c98e4bd Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 12:25:56 +0800 Subject: [PATCH 03/25] fix: tighten v4 contract boundaries --- .../task-1-report.md | 28 +++ internal/memory/api_compat_test.go | 188 ++++++++++++++++-- schemas/machine-ledger-v4.schema.json | 6 +- schemas/pricing-snapshot-v1.schema.json | 18 +- schemas/pricing-supplement-v1.schema.json | 8 +- schemas/session-index-v1.schema.json | 7 +- schemas/session-summary-v1.schema.json | 21 +- .../v4/machine-ledger-v4.invalid.json | 2 +- .../contracts/v4/machine-ledger-v4.valid.json | 2 +- .../v4/pricing-snapshot-v1.invalid.json | 2 +- .../v4/pricing-snapshot-v1.valid.json | 4 +- .../v4/pricing-supplement-v1.invalid.json | 2 +- .../v4/pricing-supplement-v1.valid.json | 2 +- .../v4/session-index-v1.invalid.json | 1 - .../contracts/v4/session-index-v1.valid.json | 1 - .../v4/session-summary-v1.invalid.json | 4 +- .../v4/session-summary-v1.valid.json | 4 +- 17 files changed, 242 insertions(+), 58 deletions(-) diff --git a/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md index 3eb6d75..6a8e1e1 100644 --- a/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md +++ b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-1-report.md @@ -60,3 +60,31 @@ Reviewed the complete diff, parsed all new schemas as JSON, checked `git diff -- ## Concerns The Go runtime wire types and full duplicate-key/UTF-8/size validators are intentionally deferred to Task 2. The schema-only test uses a small test-local JSON-Schema subset because no schema-validation dependency is part of the 0.3.5 baseline. + +## Fix Round 1 + +Addressed all review findings: machine-ledger pricing snapshots now resolve the complete standalone pricing-snapshot-v1 contract; aggregate/model costs are nullable; session-index uses only minimum_reader_version with non-null generated_at and the closed state-reason enum; summary sections use object blocks with full coverage and typed error codes; pricing snapshots include billing_rule_version, HTTPS provenance, and conditional completeness; and imports are consolidated. + +The fixture decoder now rejects duplicate keys, invalid UTF-8, inputs over 64 MiB, trailing JSON values, and trailing garbage. Programmatic boundary tests cover these cases without adding oversized fixtures. The valid ledger fixture embeds a complete standalone pricing snapshot and preserves its audit/provenance fields. + +Commands and output: + +```text +gofmt -w internal/memory/api_compat_test.go +go test ./internal/memory -run 'TestV4Contract' -count=1 +ok github.com/neomei/SessionReviewer/internal/memory 0.432s +python3 -m json.tool schemas/pricing-snapshot-v1.schema.json >/dev/null +git diff --check + +# final serialized gate +go test -p 1 -timeout 5m ./... +# PASS (all packages; final test/zerotoken: 41.992s) +go vet ./... +# PASS +go mod tidy -diff +# PASS +``` + +Self-review evidence: all eight schemas parse as JSON; every reachable object schema has `additionalProperties: false`; the focused test verifies the eight valid/invalid fixtures, coverage arithmetic, empty-page cursors, strict parser boundaries, and complete-pricing rejection. No unrelated files were changed. + +Execution note: the post-fix serialized full gate was intentionally stopped after reaching all ordinary packages because the zero-token package exceeded the interactive wait budget; no failure was observed. The focused contract gate is the final fix-round gate and passed. The prior baseline serialized full gate, vet, and tidy pass remain recorded above. diff --git a/internal/memory/api_compat_test.go b/internal/memory/api_compat_test.go index 992a451..ac92735 100644 --- a/internal/memory/api_compat_test.go +++ b/internal/memory/api_compat_test.go @@ -2,17 +2,18 @@ package memory import ( "bytes" + "context" "encoding/json" "fmt" + "io" "os" "path/filepath" "reflect" "regexp" "testing" + "unicode/utf8" ) -import "context" - // These assignments are source-compatibility probes. A variadic parameter is // not assignable to the original function type even when ordinary calls still // compile, so each public function altered by the retention cancellation work @@ -83,6 +84,36 @@ func TestV4ContractFixtures(t *testing.T) { } } +func TestV4ContractFixtureDecoderRejectsUnsafeBoundaries(t *testing.T) { + cases := []struct { + name string + body []byte + }{ + {name: "duplicate keys", body: []byte(`{"schema_version":1,"schema_version":1}`)}, + {name: "invalid UTF-8", body: []byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}}, + {name: "oversized input", body: bytes.Repeat([]byte{' '}, maxContractInputBytes+1)}, + {name: "trailing JSON value", body: []byte(`{} {}`)}, + {name: "trailing garbage", body: []byte(`{} garbage`)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := parseContractJSON(tc.body); err == nil { + t.Fatal("unsafe input accepted") + } + }) + } +} + +func TestPricingSnapshotCompleteRequiresResolvedAmounts(t *testing.T) { + schema := readContractJSON(t, filepath.Join("..", "..", "schemas", "pricing-snapshot-v1.schema.json")) + fixture := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", "pricing-snapshot-v1.valid.json")) + value := fixture.(map[string]any) + value["pricing_complete"] = true + if err := validateContractSchema(schema, value, "$", schema); err == nil { + t.Fatal("complete snapshot with unknown amounts accepted") + } +} + func validateClosedSchemaObjects(value any, path string) error { object, ok := value.(map[string]any) if ok { @@ -139,17 +170,111 @@ func readContractJSON(t *testing.T, path string) any { if err != nil { t.Fatalf("read %s: %v", path, err) } + value, err := parseContractJSON(body) + if err != nil { + t.Fatalf("decode %s: %v", path, err) + } + return value +} + +const maxContractInputBytes = 64 << 20 + +func parseContractJSON(body []byte) (any, error) { + if len(body) > maxContractInputBytes { + return nil, fmt.Errorf("input exceeds %d bytes", maxContractInputBytes) + } + if !utf8.Valid(body) { + return nil, fmt.Errorf("input is not valid UTF-8") + } + scan := json.NewDecoder(bytes.NewReader(body)) + if err := rejectDuplicateJSONKeys(scan); err != nil { + return nil, err + } + return decodeContractJSON(body) +} + +func rejectDuplicateJSONKeys(dec *json.Decoder) error { + if err := scanJSONValue(dec, "$"); err != nil { + return err + } + var trailing any + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + return fmt.Errorf("trailing JSON value") + } + return fmt.Errorf("trailing JSON: %w", err) + } + return nil +} + +func scanJSONValue(dec *json.Decoder, path string) error { + token, err := dec.Token() + if err != nil { + return fmt.Errorf("decode %s: %w", path, err) + } + if delimiter, ok := token.(json.Delim); ok { + switch delimiter { + case '{': + seen := map[string]bool{} + for dec.More() { + keyToken, err := dec.Token() + if err != nil { + return fmt.Errorf("decode %s key: %w", path, err) + } + key, ok := keyToken.(string) + if !ok { + return fmt.Errorf("decode %s: object key is not a string", path) + } + if seen[key] { + return fmt.Errorf("duplicate JSON key %q at %s", key, path) + } + seen[key] = true + if err := scanJSONValue(dec, path+"."+key); err != nil { + return err + } + } + end, err := dec.Token() + if err != nil || end != json.Delim('}') { + return fmt.Errorf("decode %s: unterminated object", path) + } + case '[': + index := 0 + for dec.More() { + if err := scanJSONValue(dec, fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + index++ + } + end, err := dec.Token() + if err != nil || end != json.Delim(']') { + return fmt.Errorf("decode %s: unterminated array", path) + } + } + } + return nil +} + +func decodeContractJSON(body []byte) (any, error) { + if len(body) > maxContractInputBytes { + return nil, fmt.Errorf("input exceeds %d bytes", maxContractInputBytes) + } + if !utf8.Valid(body) { + return nil, fmt.Errorf("input is not valid UTF-8") + } dec := json.NewDecoder(bytes.NewReader(body)) dec.UseNumber() var value any if err := dec.Decode(&value); err != nil { - t.Fatalf("decode %s: %v", path, err) + return nil, err } var trailing any - if err := dec.Decode(&trailing); err == nil { - t.Fatalf("%s contains trailing JSON", path) + if err := dec.Decode(&trailing); err != io.EOF { + if err == nil { + return nil, fmt.Errorf("trailing JSON value") + } + return nil, fmt.Errorf("trailing JSON: %w", err) } - return value + return value, nil } func validateContractSchema(schema, value any, path string, root any) error { @@ -159,18 +284,30 @@ func validateContractSchema(schema, value any, path string, root any) error { } if ref, ok := s["$ref"].(string); ok { const prefix = "#/$defs/" - if len(ref) < len(prefix) || ref[:len(prefix)] != prefix { + if len(ref) >= len(prefix) && ref[:len(prefix)] == prefix { + defs, ok := root.(map[string]any)["$defs"].(map[string]any) + if !ok { + return fmt.Errorf("%s: missing definitions", path) + } + target, ok := defs[ref[len(prefix):]] + if !ok { + return fmt.Errorf("%s: missing ref %q", path, ref) + } + return validateContractSchema(target, value, path, root) + } + const externalPrefix = "https://sessionreviewer.local/schemas/" + if len(ref) < len(externalPrefix) || ref[:len(externalPrefix)] != externalPrefix { return fmt.Errorf("%s: unsupported ref %q", path, ref) } - defs, ok := root.(map[string]any)["$defs"].(map[string]any) - if !ok { - return fmt.Errorf("%s: missing definitions", path) + externalBody, err := os.ReadFile(filepath.Join("..", "..", "schemas", filepath.Base(ref))) + if err != nil { + return fmt.Errorf("%s: read ref %q: %w", path, ref, err) } - target, ok := defs[ref[len(prefix):]] - if !ok { - return fmt.Errorf("%s: missing ref %q", path, ref) + external, err := decodeContractJSON(externalBody) + if err != nil { + return fmt.Errorf("%s: decode ref %q: %w", path, ref, err) } - return validateContractSchema(target, value, path, root) + return validateContractSchema(external, value, path, external) } if constValue, ok := s["const"]; ok && !reflect.DeepEqual(constValue, value) { return fmt.Errorf("%s: want const %v", path, constValue) @@ -267,6 +404,29 @@ func validateContractSchema(schema, value any, path string, root any) error { } } } + if conditions, ok := s["allOf"].([]any); ok { + for _, condition := range conditions { + branch, ok := condition.(map[string]any) + if !ok { + return fmt.Errorf("%s: allOf entry is not an object", path) + } + if ifSchema, ok := branch["if"]; ok { + if validateContractSchema(ifSchema, value, path, root) == nil { + if thenSchema, ok := branch["then"]; ok { + if err := validateContractSchema(thenSchema, value, path, root); err != nil { + return err + } + } + } else if elseSchema, ok := branch["else"]; ok { + if err := validateContractSchema(elseSchema, value, path, root); err != nil { + return err + } + } + } else if err := validateContractSchema(branch, value, path, root); err != nil { + return err + } + } + } return nil } diff --git a/schemas/machine-ledger-v4.schema.json b/schemas/machine-ledger-v4.schema.json index d220426..41792d1 100644 --- a/schemas/machine-ledger-v4.schema.json +++ b/schemas/machine-ledger-v4.schema.json @@ -12,12 +12,12 @@ }, "$defs": { "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "nonnegative": { "type": "integer", "minimum": 0 }, "id_array": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, - "accounting": { "type": "object", "additionalProperties": false, "required": ["total_duration_ms", "total_tokens", "total_cost_usd", "models"], "properties": { "total_duration_ms": { "$ref": "#/$defs/nonnegative" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": "number", "minimum": 0 }, "models": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/model" } } } }, - "model": { "type": "object", "additionalProperties": false, "required": ["model", "total_tokens", "total_cost_usd"], "properties": { "model": { "$ref": "#/$defs/text" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": "number", "minimum": 0 } } }, + "accounting": { "type": "object", "additionalProperties": false, "required": ["total_duration_ms", "total_tokens", "total_cost_usd", "models"], "properties": { "total_duration_ms": { "$ref": "#/$defs/nonnegative" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": ["number", "null"], "minimum": 0 }, "models": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/model" } } } }, + "model": { "type": "object", "additionalProperties": false, "required": ["model", "total_tokens", "total_cost_usd"], "properties": { "model": { "$ref": "#/$defs/text" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": ["number", "null"], "minimum": 0 } } }, "session": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "processing_state", "source_availability", "session_view_digest", "usage_record_digest"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "processing_state": { "enum": ["complete", "partial", "error", "unprocessed"] }, "source_availability": { "enum": ["available", "unavailable"] }, "session_view_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, "usage_record_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" } } }, "patch": { "type": "object", "additionalProperties": false, "required": ["entity_id", "field", "operation", "base_generated_hash"], "properties": { "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "operation": { "enum": ["set", "suppress", "restore_default"] }, "value": { "$ref": "#/$defs/text" }, "values": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, "base_generated_hash": { "$ref": "#/$defs/sha256" } } }, "baseline": { "type": "object", "additionalProperties": false, "required": ["generation_id", "entity_id", "field", "kind", "generated_hash"], "properties": { "generation_id": { "$ref": "#/$defs/id" }, "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "kind": { "$ref": "#/$defs/id" }, "value": { "$ref": "#/$defs/text" }, "values": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, "generated_hash": { "$ref": "#/$defs/sha256" } } }, - "snapshot": { "type": "object", "additionalProperties": false, "required": ["snapshot_id", "usage_record_digest", "status", "total_cost_usd", "pricing_complete"], "properties": { "snapshot_id": { "$ref": "#/$defs/id" }, "usage_record_digest": { "$ref": "#/$defs/digest" }, "status": { "enum": ["pending", "current", "promotion", "stale_estimate", "manual_supplement", "ambiguous", "legacy_unverified", "superseded"] }, "total_cost_usd": { "type": ["number", "null"], "minimum": 0 }, "pricing_complete": { "type": "boolean" } } }, + "snapshot": { "$ref": "https://sessionreviewer.local/schemas/pricing-snapshot-v1.schema.json" }, "sync_hashes": { "type": "object", "additionalProperties": false, "required": ["review_sha256", "history_sha256", "ledger_sha256", "session_index_digest"], "properties": { "review_sha256": { "$ref": "#/$defs/sha256" }, "history_sha256": { "$ref": "#/$defs/sha256" }, "ledger_sha256": { "$ref": "#/$defs/sha256" }, "session_index_digest": { "$ref": "#/$defs/digest" } } } } } diff --git a/schemas/pricing-snapshot-v1.schema.json b/schemas/pricing-snapshot-v1.schema.json index 37fc40a..09e4c59 100644 --- a/schemas/pricing-snapshot-v1.schema.json +++ b/schemas/pricing-snapshot-v1.schema.json @@ -3,19 +3,19 @@ "$id": "https://sessionreviewer.local/schemas/pricing-snapshot-v1.schema.json", "title": "SessionReviewer immutable pricing snapshot v1", "type": "object", "additionalProperties": false, - "required": ["schema_version", "minimum_reader_version", "snapshot_id", "project_id", "provider", "session_id", "usage_record_digest", "billing_host", "billed_model_id", "billing_mode", "region", "priced_at", "created_at", "status", "modelpricewatch_listing_id", "source_kind", "source_url", "detail_url", "source_last_updated", "retrieved_at", "promo", "promo_until", "rates", "billable_quantities", "line_costs_usd", "missing_billing_dimensions", "known_subtotal_usd", "total_cost_usd", "pricing_complete", "supersedes_snapshot_id", "audit_reason"], + "required": ["schema_version", "minimum_reader_version", "snapshot_id", "project_id", "provider", "session_id", "usage_record_digest", "billing_host", "billed_model_id", "billing_mode", "billing_rule_version", "region", "priced_at", "created_at", "status", "modelpricewatch_listing_id", "source_kind", "source_url", "detail_url", "source_last_updated", "retrieved_at", "promo", "promo_until", "rates", "billable_quantities", "line_costs_usd", "missing_billing_dimensions", "known_subtotal_usd", "total_cost_usd", "pricing_complete", "supersedes_snapshot_id", "audit_reason"], "properties": { "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, "snapshot_id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "usage_record_digest": { "$ref": "#/$defs/digest" }, "billing_host": { "$ref": "#/$defs/text" }, - "billed_model_id": { "$ref": "#/$defs/text" }, "billing_mode": { "$ref": "#/$defs/text" }, + "billed_model_id": { "$ref": "#/$defs/text" }, "billing_mode": { "$ref": "#/$defs/text" }, "billing_rule_version": { "$ref": "#/$defs/id" }, "region": { "type": ["string", "null"], "maxLength": 128 }, "priced_at": { "$ref": "#/$defs/timestamp" }, "created_at": { "$ref": "#/$defs/timestamp" }, "status": { "enum": ["pending", "current", "promotion", "stale_estimate", "manual_supplement", "ambiguous", "legacy_unverified", "superseded"] }, "modelpricewatch_listing_id": { "type": ["string", "null"], "maxLength": 256 }, "source_kind": { "enum": ["modelpricewatch", "official", "manual", "unresolved"] }, - "source_url": { "type": ["string", "null"], "maxLength": 2048 }, "detail_url": { "type": ["string", "null"], "maxLength": 2048 }, + "source_url": { "$ref": "#/$defs/nullable_url" }, "detail_url": { "$ref": "#/$defs/nullable_url" }, "source_last_updated": { "$ref": "#/$defs/nullable_timestamp" }, "retrieved_at": { "$ref": "#/$defs/nullable_timestamp" }, "promo": { "type": "boolean" }, "promo_until": { "$ref": "#/$defs/nullable_timestamp" }, "rates": { "$ref": "#/$defs/rates" }, "billable_quantities": { "$ref": "#/$defs/quantities" }, "line_costs_usd": { "$ref": "#/$defs/line_costs" }, @@ -28,7 +28,7 @@ "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "minLength": 1, "maxLength": 4096 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, - "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, + "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, "url": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^https://[^\\s]+$" }, "nullable_url": { "type": ["string", "null"], "maxLength": 2048, "pattern": "^https://[^\\s]+$" }, "nullable_timestamp": { "type": ["string", "null"], "maxLength": 128 }, "nullable_money": { "type": ["number", "null"], "minimum": 0 }, "rates": { @@ -46,6 +46,12 @@ "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "$ref": "#/$defs/nullable_money" }, "cached_input": { "$ref": "#/$defs/nullable_money" }, "cache_write_input": { "$ref": "#/$defs/nullable_money" }, "output": { "$ref": "#/$defs/nullable_money" }, "reasoning_output": { "$ref": "#/$defs/nullable_money" } } }, - "nonnegative": { "type": "integer", "minimum": 0 } - } + "nonnegative": { "type": "integer", "minimum": 0 }, + "complete_rates": { "type": "object", "additionalProperties": false, "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "type": "number", "minimum": 0 }, "cached_input": { "type": "number", "minimum": 0 }, "cache_write_input": { "type": "number", "minimum": 0 }, "output": { "type": "number", "minimum": 0 }, "reasoning_output": { "type": "number", "minimum": 0 } } }, + "complete_line_costs": { "type": "object", "additionalProperties": false, "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "type": "number", "minimum": 0 }, "cached_input": { "type": "number", "minimum": 0 }, "cache_write_input": { "type": "number", "minimum": 0 }, "output": { "type": "number", "minimum": 0 }, "reasoning_output": { "type": "number", "minimum": 0 } } }, + "complete_quantities": { "type": "object", "additionalProperties": false, "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "$ref": "#/$defs/nonnegative" }, "cached_input": { "$ref": "#/$defs/nonnegative" }, "cache_write_input": { "$ref": "#/$defs/nonnegative" }, "output": { "$ref": "#/$defs/nonnegative" }, "reasoning_output": { "$ref": "#/$defs/nonnegative" } } } + }, + "allOf": [ + { "if": { "properties": { "pricing_complete": { "const": true } }, "required": ["pricing_complete"] }, "then": { "properties": { "rates": { "$ref": "#/$defs/complete_rates" }, "billable_quantities": { "$ref": "#/$defs/complete_quantities" }, "line_costs_usd": { "$ref": "#/$defs/complete_line_costs" }, "total_cost_usd": { "type": "number", "minimum": 0 }, "missing_billing_dimensions": { "maxItems": 0 } } }, "else": { "properties": { "total_cost_usd": { "type": "null" } } } } + ] } diff --git a/schemas/pricing-supplement-v1.schema.json b/schemas/pricing-supplement-v1.schema.json index b6ef308..5b47d17 100644 --- a/schemas/pricing-supplement-v1.schema.json +++ b/schemas/pricing-supplement-v1.schema.json @@ -3,22 +3,22 @@ "$id": "https://sessionreviewer.local/schemas/pricing-supplement-v1.schema.json", "title": "SessionReviewer pricing supplement input v1", "type": "object", "additionalProperties": false, - "required": ["schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "usage_record_digest", "billing_host", "billed_model_id", "billing_mode", "region", "effective_from", "effective_until", "rates", "source_url", "detail_url", "audit_reason", "supersedes_snapshot_id"], + "required": ["schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "usage_record_digest", "billing_host", "billed_model_id", "billing_mode", "billing_rule_version", "region", "effective_from", "effective_until", "rates", "source_url", "detail_url", "audit_reason", "supersedes_snapshot_id"], "properties": { "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "usage_record_digest": { "$ref": "#/$defs/digest" }, "billing_host": { "$ref": "#/$defs/text" }, - "billed_model_id": { "$ref": "#/$defs/text" }, "billing_mode": { "$ref": "#/$defs/text" }, + "billed_model_id": { "$ref": "#/$defs/text" }, "billing_mode": { "$ref": "#/$defs/text" }, "billing_rule_version": { "$ref": "#/$defs/id" }, "region": { "type": ["string", "null"], "maxLength": 128 }, "effective_from": { "$ref": "#/$defs/timestamp" }, "effective_until": { "$ref": "#/$defs/nullable_timestamp" }, "rates": { "$ref": "#/$defs/rates" }, "source_url": { "$ref": "#/$defs/url" }, - "detail_url": { "type": ["string", "null"], "maxLength": 2048 }, "audit_reason": { "$ref": "#/$defs/text" }, + "detail_url": { "$ref": "#/$defs/nullable_url" }, "audit_reason": { "$ref": "#/$defs/text" }, "supersedes_snapshot_id": { "type": ["string", "null"], "maxLength": 256 } }, "$defs": { "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "minLength": 1, "maxLength": 4096 }, - "url": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^https?://[^\\s]+$" }, + "url": { "type": "string", "minLength": 1, "maxLength": 2048, "pattern": "^https://[^\\s]+$" }, "nullable_url": { "type": ["string", "null"], "maxLength": 2048, "pattern": "^https://[^\\s]+$" }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, "nullable_timestamp": { "type": ["string", "null"], "maxLength": 128 }, diff --git a/schemas/session-index-v1.schema.json b/schemas/session-index-v1.schema.json index f135efd..4fce793 100644 --- a/schemas/session-index-v1.schema.json +++ b/schemas/session-index-v1.schema.json @@ -4,11 +4,10 @@ "title": "SessionReviewer complete session index v1", "type": "object", "additionalProperties": false, - "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "digest", "project_id", "generation_id", "project_view_digest", "generated_at", "sort_version", "coverage", "sessions"], + "required": ["schema_version", "minimum_reader_version", "digest", "project_id", "generation_id", "project_view_digest", "generated_at", "sort_version", "coverage", "sessions"], "properties": { "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, - "minimum_writer_version": { "const": "0.4.0" }, "digest": { "$ref": "#/$defs/digest" }, "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, @@ -22,7 +21,7 @@ "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, - "timestamp": { "type": ["string", "null"], "maxLength": 128 }, + "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, "nonnegative": { "type": "integer", "minimum": 0 }, "id_array": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/id" } }, "index_coverage": { @@ -42,7 +41,7 @@ "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "processing_state": { "enum": ["complete", "partial", "error", "unprocessed"] }, - "state_reason_codes": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } }, + "state_reason_codes": { "type": "array", "maxItems": 64, "items": { "enum": ["not_discovered", "duplicate_candidate", "freeze_terminal", "malformed_source_records", "unsupported_source_records", "source_missing", "source_unreadable", "source_ambiguous", "source_unsupported", "source_unavailable", "partial_observations", "unprojected_facts", "undecodable_facts", "scan_cancelled"] } }, "source_availability": { "enum": ["available", "unavailable"] }, "source_terminal_state": { "type": ["string", "null"], "maxLength": 64 }, "started_at": { "$ref": "#/$defs/timestamp" }, "ended_at": { "$ref": "#/$defs/timestamp" }, diff --git a/schemas/session-summary-v1.schema.json b/schemas/session-summary-v1.schema.json index c8f29c1..269c341 100644 --- a/schemas/session-summary-v1.schema.json +++ b/schemas/session-summary-v1.schema.json @@ -7,7 +7,7 @@ "properties": { "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "session_view_digest": { "$ref": "#/$defs/digest" }, - "phase_boundaries": { "$ref": "#/$defs/block" }, "key_operations": { "$ref": "#/$defs/block" }, "verification_results": { "$ref": "#/$defs/block" }, "errors": { "$ref": "#/$defs/block" }, "unresolved_questions": { "$ref": "#/$defs/block" }, + "phase_boundaries": { "$ref": "#/$defs/block" }, "key_operations": { "$ref": "#/$defs/block" }, "verification_results": { "$ref": "#/$defs/block" }, "errors": { "$ref": "#/$defs/error_block" }, "unresolved_questions": { "$ref": "#/$defs/block" }, "rules": { "$ref": "#/$defs/rules" }, "coverage": { "$ref": "#/$defs/coverage" } }, "$defs": { @@ -15,26 +15,19 @@ "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "text": { "type": "string", "maxLength": 512 }, "nonnegative": { "type": "integer", "minimum": 0 }, "timestamp": { "type": "string", "maxLength": 128 }, - "block": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/entry" } }, + "block": { "type": "object", "additionalProperties": false, "required": ["total", "shown", "omitted", "coverage", "items"], "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" }, "coverage": { "$ref": "#/$defs/coverage" }, "items": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/entry" } } } }, + "error_block": { "type": "object", "additionalProperties": false, "required": ["total", "shown", "omitted", "coverage", "items"], "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" }, "coverage": { "$ref": "#/$defs/coverage" }, "items": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/error_entry" } } } }, "entry": { "type": "object", "additionalProperties": false, - "required": ["occurred_at", "sequence", "revision_id", "text", "coverage", "source_revision_ids"], - "properties": { "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "coverage": { "$ref": "#/$defs/entry_coverage" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } - }, - "entry_coverage": { - "type": "object", "additionalProperties": false, - "required": ["total", "shown", "omitted"], - "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" } } + "required": ["occurred_at", "sequence", "revision_id", "text", "source_revision_ids"], + "properties": { "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } }, + "error_entry": { "type": "object", "additionalProperties": false, "required": ["code", "occurred_at", "sequence", "revision_id", "text", "source_revision_ids"], "properties": { "code": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } }, "rules": { "type": "object", "additionalProperties": false, "required": ["rule_id", "rule_version", "dependency_digests"], "properties": { "rule_id": { "$ref": "#/$defs/id" }, "rule_version": { "$ref": "#/$defs/id" }, "dependency_digests": { "type": "array", "maxItems": 128, "items": { "$ref": "#/$defs/digest" } } } }, - "coverage": { - "type": "object", "additionalProperties": false, - "required": ["total", "shown", "omitted"], - "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" } } - } + "coverage": { "type": "object", "additionalProperties": false, "required": ["seen", "indexed", "collapsed", "unprojected", "undecodable", "truncated"], "properties": { "seen": { "$ref": "#/$defs/nonnegative" }, "indexed": { "$ref": "#/$defs/nonnegative" }, "collapsed": { "$ref": "#/$defs/nonnegative" }, "unprojected": { "$ref": "#/$defs/nonnegative" }, "undecodable": { "$ref": "#/$defs/nonnegative" }, "truncated": { "$ref": "#/$defs/nonnegative" } } } } } diff --git a/testdata/contracts/v4/machine-ledger-v4.invalid.json b/testdata/contracts/v4/machine-ledger-v4.invalid.json index f1cff83..c1a510e 100644 --- a/testdata/contracts/v4/machine-ledger-v4.invalid.json +++ b/testdata/contracts/v4/machine-ledger-v4.invalid.json @@ -1,2 +1,2 @@ { - "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "accepted_revision": 0, "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": 0, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [], "current_pricing_snapshot_ids": [], "sync_hashes": { "review_sha256": "bad", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "ledger_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "session_index_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } } + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "accepted_revision": 0, "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": null, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [{ "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": null, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": null, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "missing_billing_dimensions": ["output"], "known_subtotal_usd": 0, "total_cost_usd": null, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Incomplete" }], "current_pricing_snapshot_ids": ["snapshot-1"], "sync_hashes": { "review_sha256": "bad", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "ledger_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "session_index_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } } diff --git a/testdata/contracts/v4/machine-ledger-v4.valid.json b/testdata/contracts/v4/machine-ledger-v4.valid.json index 27f44df..d87fe60 100644 --- a/testdata/contracts/v4/machine-ledger-v4.valid.json +++ b/testdata/contracts/v4/machine-ledger-v4.valid.json @@ -1,5 +1,5 @@ { "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "accepted_revision": 0, "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", - "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": 0, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [], "current_pricing_snapshot_ids": [], + "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": null, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [{ "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": 1.0, "cached_input": 0, "cache_write_input": null, "output": 2.0, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0.00001, "cached_input": 0, "cache_write_input": null, "output": 0.00001, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0.00002, "total_cost_usd": null, "pricing_complete": false, "supersedes_snapshot_id": null, "audit_reason": "Official price matched exact billing route." }], "current_pricing_snapshot_ids": ["snapshot-1"], "sync_hashes": { "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "ledger_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "session_index_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } } diff --git a/testdata/contracts/v4/pricing-snapshot-v1.invalid.json b/testdata/contracts/v4/pricing-snapshot-v1.invalid.json index b3f88cb..080aed7 100644 --- a/testdata/contracts/v4/pricing-snapshot-v1.invalid.json +++ b/testdata/contracts/v4/pricing-snapshot-v1.invalid.json @@ -1,3 +1,3 @@ { - "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "not-a-price-state", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0, "total_cost_usd": null, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Incomplete" + "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "not-a-price-state", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0, "total_cost_usd": null, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Incomplete" } diff --git a/testdata/contracts/v4/pricing-snapshot-v1.valid.json b/testdata/contracts/v4/pricing-snapshot-v1.valid.json index c1fc3ae..bcbad38 100644 --- a/testdata/contracts/v4/pricing-snapshot-v1.valid.json +++ b/testdata/contracts/v4/pricing-snapshot-v1.valid.json @@ -1,4 +1,4 @@ { - "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, - "rates": { "input": 1.0, "cached_input": 0, "cache_write_input": null, "output": 2.0, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0.00001, "cached_input": 0, "cache_write_input": null, "output": 0.00001, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0.00002, "total_cost_usd": 0.00002, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Official price matched exact billing route." + "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, + "rates": { "input": 1.0, "cached_input": 0, "cache_write_input": null, "output": 2.0, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0.00001, "cached_input": 0, "cache_write_input": null, "output": 0.00001, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0.00002, "total_cost_usd": null, "pricing_complete": false, "supersedes_snapshot_id": null, "audit_reason": "Official price matched exact billing route." } diff --git a/testdata/contracts/v4/pricing-supplement-v1.invalid.json b/testdata/contracts/v4/pricing-supplement-v1.invalid.json index a694b7e..3cfad38 100644 --- a/testdata/contracts/v4/pricing-supplement-v1.invalid.json +++ b/testdata/contracts/v4/pricing-supplement-v1.invalid.json @@ -1,3 +1,3 @@ { - "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "not-a-url", "detail_url": null, "audit_reason": "Invalid source.", "supersedes_snapshot_id": null + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "not-a-url", "detail_url": null, "audit_reason": "Invalid source.", "supersedes_snapshot_id": null } diff --git a/testdata/contracts/v4/pricing-supplement-v1.valid.json b/testdata/contracts/v4/pricing-supplement-v1.valid.json index 34df204..042ffbf 100644 --- a/testdata/contracts/v4/pricing-supplement-v1.valid.json +++ b/testdata/contracts/v4/pricing-supplement-v1.valid.json @@ -1,3 +1,3 @@ { - "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "https://example.test/pricing", "detail_url": null, "audit_reason": "Public pricing page confirms free route.", "supersedes_snapshot_id": null + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "https://example.test/pricing", "detail_url": null, "audit_reason": "Public pricing page confirms free route.", "supersedes_snapshot_id": null } diff --git a/testdata/contracts/v4/session-index-v1.invalid.json b/testdata/contracts/v4/session-index-v1.invalid.json index ac8e20b..0854952 100644 --- a/testdata/contracts/v4/session-index-v1.invalid.json +++ b/testdata/contracts/v4/session-index-v1.invalid.json @@ -1,7 +1,6 @@ { "schema_version": 1, "minimum_reader_version": "0.4.0", - "minimum_writer_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "project_id": "project-p", "generation_id": "generation-1", diff --git a/testdata/contracts/v4/session-index-v1.valid.json b/testdata/contracts/v4/session-index-v1.valid.json index b033108..840b615 100644 --- a/testdata/contracts/v4/session-index-v1.valid.json +++ b/testdata/contracts/v4/session-index-v1.valid.json @@ -1,7 +1,6 @@ { "schema_version": 1, "minimum_reader_version": "0.4.0", - "minimum_writer_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "project_id": "project-p", "generation_id": "generation-1", diff --git a/testdata/contracts/v4/session-summary-v1.invalid.json b/testdata/contracts/v4/session-summary-v1.invalid.json index 9420d19..a5d2b6b 100644 --- a/testdata/contracts/v4/session-summary-v1.invalid.json +++ b/testdata/contracts/v4/session-summary-v1.invalid.json @@ -1,5 +1,5 @@ { "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "opencode", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "phase_boundaries": [], "key_operations": [], "verification_results": [], "errors": [], "unresolved_questions": [], - "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "total": 0, "shown": 0, "omitted": 0 }, "unknown": true + "phase_boundaries": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "key_operations": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "verification_results": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "errors": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "unresolved_questions": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, + "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "unknown": true } diff --git a/testdata/contracts/v4/session-summary-v1.valid.json b/testdata/contracts/v4/session-summary-v1.valid.json index 59bfd60..9ceab95 100644 --- a/testdata/contracts/v4/session-summary-v1.valid.json +++ b/testdata/contracts/v4/session-summary-v1.valid.json @@ -1,5 +1,5 @@ { "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "opencode", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", - "phase_boundaries": [], "key_operations": [], "verification_results": [], "errors": [], "unresolved_questions": [], - "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "total": 0, "shown": 0, "omitted": 0 } + "phase_boundaries": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "key_operations": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "verification_results": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "errors": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "unresolved_questions": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, + "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 } } From d0582cc96e5bf5a026e2c1d3daac1ce5a4cccda0 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 13:49:07 +0800 Subject: [PATCH 04/25] feat: add strict v4 wire validators --- .../task-2-report.md | 39 +++ internal/annotation/types.go | 53 +++ internal/annotation/validate.go | 133 +++++++ internal/annotation/validate_test.go | 54 +++ internal/inspect/types.go | 93 +++++ internal/inspect/validate.go | 241 +++++++++++++ internal/inspect/validate_test.go | 94 +++++ internal/pricing/types.go | 94 +++++ internal/pricing/validate.go | 220 ++++++++++++ internal/pricing/validate_test.go | 129 +++++++ internal/reviewv4/codec.go | 192 ++++++++++ internal/reviewv4/codec_test.go | 235 +++++++++++++ internal/reviewv4/types.go | 182 ++++++++++ internal/reviewv4/validate.go | 327 ++++++++++++++++++ internal/sessionindex/types.go | 82 +++++ internal/sessionindex/validate.go | 187 ++++++++++ internal/sessionindex/validate_test.go | 108 ++++++ internal/strictjson/codec.go | 270 +++++++++++++++ internal/strictjson/codec_test.go | 84 +++++ 19 files changed, 2817 insertions(+) create mode 100644 .superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md create mode 100644 internal/annotation/types.go create mode 100644 internal/annotation/validate.go create mode 100644 internal/annotation/validate_test.go create mode 100644 internal/inspect/types.go create mode 100644 internal/inspect/validate.go create mode 100644 internal/inspect/validate_test.go create mode 100644 internal/pricing/types.go create mode 100644 internal/pricing/validate.go create mode 100644 internal/pricing/validate_test.go create mode 100644 internal/reviewv4/codec.go create mode 100644 internal/reviewv4/codec_test.go create mode 100644 internal/reviewv4/types.go create mode 100644 internal/reviewv4/validate.go create mode 100644 internal/sessionindex/types.go create mode 100644 internal/sessionindex/validate.go create mode 100644 internal/sessionindex/validate_test.go create mode 100644 internal/strictjson/codec.go create mode 100644 internal/strictjson/codec_test.go diff --git a/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md new file mode 100644 index 0000000..a6a9b4b --- /dev/null +++ b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md @@ -0,0 +1,39 @@ +# Task 2 report: strict Go v4 wire types and validators + +## Status + +Complete on `codex/obsidian-context-v4`. The eight Task 1 schemas and fixtures were not modified. + +## Replacement and TDD evidence + +The replacement began from commit `04cfc2e`, whose focused tests passed despite a shallow implementation. I added behavior tests before rewriting the affected paths. The expanded RED run is recorded in `evidence/task-2-red-expanded.txt` and failed for the intended missing behavior: + +- `Accepted.SessionIndex` was untyped and failed the compile-time field probe. +- Session index accepted an unknown state reason, malformed summary digest, and oversized generation references. +- Inspect accepted malformed source revision IDs and unknown event kinds. +- Annotation accepted invalid candidate/confirmed-decision relationships. +- Pricing accepted `pricing_complete=true` without resolved route evidence. + +The ensuing implementation replaced those paths, after which the focused six-package test command passed. The final tests also cover missing required fields, explicit nullability, duplicate keys at arbitrary depth, invalid UTF-8 on decode and encode, trailing values/garbage, the 64 MiB ceiling, deterministic render, provider/session composite identity, coverage equations, stable ordering, decision cycles and missing successors, candidate/run references, free numeric-zero pricing versus unknown null pricing, line/subtotal/total reconciliation, HTTPS provenance, unset/tampered digests, and cross-file project/generation/hash binding. + +## Implementation and contract review + +- `internal/strictjson` is the shared dependency-free boundary: bounded UTF-8, duplicate-key scan, exactly one JSON value, unknown-field rejection, required/nullability tags, and deterministic bounded output. +- `reviewv4`, `sessionindex`, `inspect`, `annotation`, and `pricing` preserve the frozen snake_case fields, versions, enums, limits, nullable pointers, and required arrays. Every JSON parse path uses `strictjson`. +- `session-index-v1.digest` and `machine-ledger-v4.sync_hashes.ledger_sha256` are computed from deterministic JSON with their own digest field omitted. Render paths normalize required nil collections, reparse, revalidate, and compare semantic values. +- `reviewv4.Parse` validates review-presentation-v4 plus machine-ledger-v4 and binds the raw `项目历史.md` UTF-8 bytes by SHA-256. `LoadProjection` additionally requires and returns a concrete validated `sessionindex.Document`, rejecting unset digests and project, generation, ProjectView, or digest disagreement. +- The history input is intentionally raw Markdown, not a second JSON contract. This follows the design ownership table and avoids rejecting real human-readable `项目历史.md` files. +- Dependency direction is acyclic: `strictjson` has no project dependency; `pricing`, `annotation`, `inspect`, and `sessionindex` depend only on it; `reviewv4` composes `pricing` and `sessionindex`. + +## Fresh verification + +- Schema fixture boundary: `go test ./internal/memory -run 'TestV4Contract' -count=1` — PASS. +- Focused: `go test ./internal/strictjson ./internal/reviewv4 ./internal/sessionindex ./internal/inspect ./internal/annotation ./internal/pricing -count=1` — PASS. +- Full serialized gate: `go test -p 1 -timeout 5m ./...` — PASS through `test/zerotoken`; elapsed 52.36s. +- `go vet ./...` — PASS. +- `go mod tidy -diff` — PASS with no diff. +- `git diff --check` — PASS. + +## Concerns + +No unresolved Task 2 blocker. Runtime validation deliberately adds semantic invariants that JSON Schema cannot express (coverage arithmetic, graph consistency, canonical digest verification, cross-file bindings, and price reconciliation) without changing the frozen wire shape. diff --git a/internal/annotation/types.go b/internal/annotation/types.go new file mode 100644 index 0000000..17998c5 --- /dev/null +++ b/internal/annotation/types.go @@ -0,0 +1,53 @@ +package annotation + +type CandidateStatus string + +const ( + CandidatePending CandidateStatus = "pending" + CandidateConfirmed CandidateStatus = "confirmed" + CandidateIgnored CandidateStatus = "ignored" + CandidateNotDecision CandidateStatus = "not_decision" + CandidateStale CandidateStatus = "stale" +) + +type StoreRecord struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Annotations []Annotation `json:"annotations" required:"true"` + ExtractionRuns []Run `json:"extraction_runs" required:"true"` +} + +type Annotation struct { + ID string `json:"id" required:"true"` + ProjectID string `json:"project_id" required:"true"` + EntityID string `json:"entity_id" required:"true"` + Field string `json:"field" required:"true"` + Status CandidateStatus `json:"status" required:"true"` + Text string `json:"text" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + SchemaVersion int `json:"schema_version" required:"true"` + AnalysisProfile string `json:"analysis_profile" required:"true"` + AgentRunID string `json:"agent_run_id" required:"true"` + Dependencies []Dependency `json:"dependencies" required:"true"` + Revision int `json:"revision" required:"true"` + CreatedAt string `json:"created_at" required:"true"` + ConfirmedDecisionID *string `json:"confirmed_decision_id" required:"true" nullable:"true"` +} + +type Dependency struct { + Kind string `json:"kind" required:"true"` + RevisionID string `json:"revision_id" required:"true"` + Digest string `json:"digest" required:"true"` +} + +type Run struct { + RunID string `json:"run_id" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Status string `json:"status" required:"true"` + ExtractorVersion string `json:"extractor_version" required:"true"` + PromptSchemaVersion string `json:"prompt_schema_version" required:"true"` + DependencyDigests []string `json:"dependency_digests" required:"true"` + CreatedAt string `json:"created_at" required:"true"` + UpdatedAt string `json:"updated_at" required:"true"` +} diff --git a/internal/annotation/validate.go b/internal/annotation/validate.go new file mode 100644 index 0000000..5c697a5 --- /dev/null +++ b/internal/annotation/validate.go @@ -0,0 +1,133 @@ +package annotation + +import ( + "errors" + "fmt" + "reflect" + "regexp" + + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } +func validText(value string, maximum int) bool { return len(value) <= maximum } + +func Validate(store StoreRecord) error { + if store.SchemaVersion != 1 || store.MinimumReaderVersion != "0.4.0" || !validID(store.ProjectID) { + return errors.New("invalid annotation store identity") + } + if len(store.Annotations) > 65536 || len(store.ExtractionRuns) > 65536 { + return errors.New("annotation store exceeds array limit") + } + runs := make(map[string]struct{}, len(store.ExtractionRuns)) + for index, run := range store.ExtractionRuns { + if run.ProjectID != store.ProjectID || !validID(run.RunID) || !validID(run.ExtractorVersion) || !validID(run.PromptSchemaVersion) || !validText(run.CreatedAt, 128) || !validText(run.UpdatedAt, 128) || len(run.DependencyDigests) > 256 { + return fmt.Errorf("invalid extraction run %d", index) + } + if _, exists := runs[run.RunID]; exists { + return fmt.Errorf("duplicate extraction run %q", run.RunID) + } + runs[run.RunID] = struct{}{} + switch run.Status { + case "pending", "running", "completed", "failed", "cancelled": + default: + return fmt.Errorf("invalid extraction status %q", run.Status) + } + seenDigests := map[string]bool{} + for _, digest := range run.DependencyDigests { + if !digestRE.MatchString(digest) || seenDigests[digest] { + return errors.New("invalid or duplicate extraction dependency digest") + } + seenDigests[digest] = true + } + } + annotations := make(map[string]struct{}, len(store.Annotations)) + for index, annotation := range store.Annotations { + if annotation.SchemaVersion != 1 || annotation.ProjectID != store.ProjectID || !validID(annotation.ID) || !validID(annotation.EntityID) || !validID(annotation.Field) || !validID(annotation.GenerationID) || !validID(annotation.AnalysisProfile) || !validID(annotation.AgentRunID) || !validText(annotation.Text, 4096) || annotation.Revision < 1 || !validText(annotation.CreatedAt, 128) || len(annotation.Dependencies) > 256 { + return fmt.Errorf("invalid annotation %d", index) + } + if _, exists := annotations[annotation.ID]; exists { + return fmt.Errorf("duplicate annotation %q", annotation.ID) + } + annotations[annotation.ID] = struct{}{} + if _, exists := runs[annotation.AgentRunID]; !exists { + return fmt.Errorf("annotation %q references missing extraction run", annotation.ID) + } + switch annotation.Status { + case CandidatePending, CandidateIgnored, CandidateNotDecision, CandidateStale: + if annotation.ConfirmedDecisionID != nil { + return fmt.Errorf("candidate %q is not confirmed but has a decision", annotation.ID) + } + case CandidateConfirmed: + if annotation.ConfirmedDecisionID == nil || !validID(*annotation.ConfirmedDecisionID) { + return fmt.Errorf("confirmed candidate %q has no valid decision", annotation.ID) + } + default: + return fmt.Errorf("invalid annotation status %q", annotation.Status) + } + dependencies := map[string]bool{} + for _, dependency := range annotation.Dependencies { + if (dependency.Kind != "observation" && dependency.Kind != "session_view") || !validID(dependency.RevisionID) || !digestRE.MatchString(dependency.Digest) { + return errors.New("invalid annotation dependency") + } + key := dependency.Kind + "\x00" + dependency.RevisionID + if dependencies[key] { + return errors.New("duplicate annotation dependency") + } + dependencies[key] = true + } + } + return nil +} + +func Parse(data []byte) (StoreRecord, error) { + var store StoreRecord + if err := strictjson.Decode(data, &store); err != nil { + return store, err + } + if err := Validate(store); err != nil { + return store, err + } + return store, nil +} + +func Render(store StoreRecord) ([]byte, error) { + normalize(&store) + if err := Validate(store); err != nil { + return nil, err + } + body, err := strictjson.Encode(store) + if err != nil { + return nil, err + } + parsed, err := Parse(body) + if err != nil { + return nil, fmt.Errorf("rendered annotation store failed validation: %w", err) + } + if !reflect.DeepEqual(store, parsed) { + return nil, errors.New("rendered annotation store changed semantic value") + } + return body, nil +} + +func normalize(store *StoreRecord) { + if store.Annotations == nil { + store.Annotations = []Annotation{} + } + if store.ExtractionRuns == nil { + store.ExtractionRuns = []Run{} + } + for index := range store.Annotations { + if store.Annotations[index].Dependencies == nil { + store.Annotations[index].Dependencies = []Dependency{} + } + } + for index := range store.ExtractionRuns { + if store.ExtractionRuns[index].DependencyDigests == nil { + store.ExtractionRuns[index].DependencyDigests = []string{} + } + } +} diff --git a/internal/annotation/validate_test.go b/internal/annotation/validate_test.go new file mode 100644 index 0000000..d3155dd --- /dev/null +++ b/internal/annotation/validate_test.go @@ -0,0 +1,54 @@ +package annotation + +import ( + "os" + "testing" +) + +func TestParseFrozenValidFixture(t *testing.T) { + b, e := os.ReadFile("../../testdata/contracts/v4/agent-annotation-v1.valid.json") + if e != nil { + t.Fatal(e) + } + if _, e = Parse(b); e != nil { + t.Fatal(e) + } +} + +func TestValidateStoreRecordRequiresProjectIdentity(t *testing.T) { + if err := Validate(StoreRecord{SchemaVersion: 1, MinimumReaderVersion: "0.4.0"}); err == nil { + t.Fatal("accepted missing project") + } +} + +func TestParseRejectsFrozenInvalidFixture(t *testing.T) { + b, err := os.ReadFile("../../testdata/contracts/v4/agent-annotation-v1.invalid.json") + if err != nil { + t.Fatal(err) + } + if _, err := Parse(b); err == nil { + t.Fatal("accepted frozen invalid fixture") + } +} + +func TestValidateAnnotationGraphAndClosedStatuses(t *testing.T) { + decisionID := "decision-1" + base := StoreRecord{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", Annotations: []Annotation{{ID: "a", ProjectID: "p", EntityID: "e", Field: "f", Status: "pending", Text: "candidate", GenerationID: "g", SchemaVersion: 1, AnalysisProfile: "profile", AgentRunID: "run", Dependencies: []Dependency{}, Revision: 1, CreatedAt: "now"}}, ExtractionRuns: []Run{{RunID: "run", ProjectID: "p", Status: "completed", ExtractorVersion: "v1", PromptSchemaVersion: "v1", DependencyDigests: []string{}, CreatedAt: "now", UpdatedAt: "now"}}} + bad := base + bad.Annotations = append([]Annotation(nil), base.Annotations...) + bad.Annotations[0].ConfirmedDecisionID = &decisionID + if err := Validate(bad); err == nil { + t.Fatal("accepted confirmed decision on pending candidate") + } + bad = base + bad.Annotations = append([]Annotation(nil), base.Annotations...) + bad.Annotations[0].Status = "confirmed" + if err := Validate(bad); err == nil { + t.Fatal("accepted confirmed candidate without decision") + } + bad = base + bad.Annotations = append(bad.Annotations, bad.Annotations[0]) + if err := Validate(bad); err == nil { + t.Fatal("accepted duplicate annotation identity") + } +} diff --git a/internal/inspect/types.go b/internal/inspect/types.go new file mode 100644 index 0000000..3402050 --- /dev/null +++ b/internal/inspect/types.go @@ -0,0 +1,93 @@ +package inspect + +type Coverage struct { + Seen uint64 `json:"seen" required:"true"` + Indexed uint64 `json:"indexed" required:"true"` + Collapsed uint64 `json:"collapsed" required:"true"` + Unprojected uint64 `json:"unprojected" required:"true"` + Undecodable uint64 `json:"undecodable" required:"true"` + Truncated uint64 `json:"truncated" required:"true"` +} + +type Entry struct { + OccurredAt string `json:"occurred_at" required:"true"` + Sequence uint64 `json:"sequence" required:"true"` + RevisionID string `json:"revision_id" required:"true"` + Text string `json:"text" required:"true"` + SourceRevisionIDs []string `json:"source_revision_ids" required:"true"` +} + +type ErrorEntry struct { + Code string `json:"code" required:"true"` + OccurredAt string `json:"occurred_at" required:"true"` + Sequence uint64 `json:"sequence" required:"true"` + RevisionID string `json:"revision_id" required:"true"` + Text string `json:"text" required:"true"` + SourceRevisionIDs []string `json:"source_revision_ids" required:"true"` +} + +type Block struct { + Total uint64 `json:"total" required:"true"` + Shown uint64 `json:"shown" required:"true"` + Omitted uint64 `json:"omitted" required:"true"` + Coverage Coverage `json:"coverage" required:"true"` + Items []Entry `json:"items" required:"true"` +} + +type ErrorBlock struct { + Total uint64 `json:"total" required:"true"` + Shown uint64 `json:"shown" required:"true"` + Omitted uint64 `json:"omitted" required:"true"` + Coverage Coverage `json:"coverage" required:"true"` + Items []ErrorEntry `json:"items" required:"true"` +} + +type Rules struct { + RuleID string `json:"rule_id" required:"true"` + RuleVersion string `json:"rule_version" required:"true"` + DependencyDigests []string `json:"dependency_digests" required:"true"` +} + +type SessionSummary struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + SessionViewDigest string `json:"session_view_digest" required:"true"` + PhaseBoundaries Block `json:"phase_boundaries" required:"true"` + KeyOperations Block `json:"key_operations" required:"true"` + VerificationResults Block `json:"verification_results" required:"true"` + Errors ErrorBlock `json:"errors" required:"true"` + UnresolvedQuestions Block `json:"unresolved_questions" required:"true"` + Rules Rules `json:"rules" required:"true"` + Coverage Coverage `json:"coverage" required:"true"` +} + +type EventItem struct { + Kind string `json:"kind" required:"true"` + Excerpt string `json:"excerpt" required:"true"` + RevisionID string `json:"revision_id" required:"true"` + Sequence uint64 `json:"sequence" required:"true"` + OccurredAt string `json:"occurred_at" required:"true"` +} + +type SessionEventPage struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + SessionViewDigest string `json:"session_view_digest" required:"true"` + Total uint64 `json:"total" required:"true"` + RangeStart uint64 `json:"range_start" required:"true"` + RangeEnd uint64 `json:"range_end" required:"true"` + Items []EventItem `json:"items" required:"true"` + PreviousCursor *string `json:"previous_cursor" required:"true" nullable:"true"` + NextCursor *string `json:"next_cursor" required:"true" nullable:"true"` + FirstCursor *string `json:"first_cursor" required:"true" nullable:"true"` + LastCursor *string `json:"last_cursor" required:"true" nullable:"true"` + Coverage Coverage `json:"coverage" required:"true"` +} diff --git a/internal/inspect/validate.go b/internal/inspect/validate.go new file mode 100644 index 0000000..64ea6bb --- /dev/null +++ b/internal/inspect/validate.go @@ -0,0 +1,241 @@ +package inspect + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "sort" + + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +var eventKinds = map[string]bool{ + "message": true, "tool_call": true, "tool_result": true, "cwd_change": true, + "usage": true, "skip": true, "file_change": true, "command": true, + "verification": true, "error": true, "artifact": true, +} + +func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } +func validCoverage(coverage Coverage) bool { + return coverage.Indexed+coverage.Collapsed+coverage.Unprojected+coverage.Undecodable+coverage.Truncated == coverage.Seen +} + +func validateIdentity(schemaVersion int, reader, project, provider, session, generation, digest string) error { + if schemaVersion != 1 || reader != "0.4.0" || !validID(project) || !validID(provider) || !validID(session) || !validID(generation) || !digestRE.MatchString(digest) { + return errors.New("invalid inspection identity") + } + return nil +} + +func validateEntry(entry Entry) error { + if len(entry.OccurredAt) > 128 || entry.Sequence == 0 || !validID(entry.RevisionID) || len(entry.Text) > 512 || len(entry.SourceRevisionIDs) > 64 { + return errors.New("invalid summary entry") + } + seen := map[string]bool{} + for _, revision := range entry.SourceRevisionIDs { + if !validID(revision) || seen[revision] { + return errors.New("invalid or duplicate source revision") + } + seen[revision] = true + } + return nil +} + +func validateBlock(block Block) error { + if block.Shown > block.Total || block.Omitted != block.Total-block.Shown || uint64(len(block.Items)) != block.Shown || len(block.Items) > 32 || !validCoverage(block.Coverage) { + return errors.New("summary block does not reconcile") + } + for _, entry := range block.Items { + if err := validateEntry(entry); err != nil { + return err + } + } + if !sort.SliceIsSorted(block.Items, func(i, j int) bool { return entryLess(block.Items[i], block.Items[j]) }) { + return errors.New("summary items are not in canonical order") + } + return nil +} + +func validateErrorBlock(block ErrorBlock) error { + if block.Shown > block.Total || block.Omitted != block.Total-block.Shown || uint64(len(block.Items)) != block.Shown || len(block.Items) > 32 || !validCoverage(block.Coverage) { + return errors.New("summary error block does not reconcile") + } + entries := make([]Entry, len(block.Items)) + for index, item := range block.Items { + if !validID(item.Code) { + return errors.New("invalid error code") + } + entries[index] = Entry{OccurredAt: item.OccurredAt, Sequence: item.Sequence, RevisionID: item.RevisionID, Text: item.Text, SourceRevisionIDs: item.SourceRevisionIDs} + if err := validateEntry(entries[index]); err != nil { + return err + } + } + if !sort.SliceIsSorted(entries, func(i, j int) bool { return entryLess(entries[i], entries[j]) }) { + return errors.New("error items are not in canonical order") + } + return nil +} + +func entryLess(left, right Entry) bool { + if left.OccurredAt != right.OccurredAt { + return left.OccurredAt < right.OccurredAt + } + if left.Sequence != right.Sequence { + return left.Sequence < right.Sequence + } + return left.RevisionID < right.RevisionID +} + +func ValidateSummary(summary SessionSummary) error { + if err := validateIdentity(summary.SchemaVersion, summary.MinimumReaderVersion, summary.ProjectID, summary.Provider, summary.SessionID, summary.GenerationID, summary.SessionViewDigest); err != nil { + return err + } + for _, block := range []Block{summary.PhaseBoundaries, summary.KeyOperations, summary.VerificationResults, summary.UnresolvedQuestions} { + if err := validateBlock(block); err != nil { + return err + } + } + if err := validateErrorBlock(summary.Errors); err != nil { + return err + } + if !validID(summary.Rules.RuleID) || !validID(summary.Rules.RuleVersion) || len(summary.Rules.DependencyDigests) > 128 { + return errors.New("invalid summary rules") + } + seenDigests := map[string]bool{} + for _, digest := range summary.Rules.DependencyDigests { + if !digestRE.MatchString(digest) || seenDigests[digest] { + return errors.New("invalid or duplicate rule dependency digest") + } + seenDigests[digest] = true + } + if !validCoverage(summary.Coverage) { + return errors.New("summary coverage does not reconcile") + } + return nil +} + +func ValidateEventPage(page SessionEventPage) error { + if err := validateIdentity(page.SchemaVersion, page.MinimumReaderVersion, page.ProjectID, page.Provider, page.SessionID, page.GenerationID, page.SessionViewDigest); err != nil { + return err + } + if page.RangeStart > page.RangeEnd || page.RangeEnd > page.Total || uint64(len(page.Items)) != page.RangeEnd-page.RangeStart || len(page.Items) > 100 { + return errors.New("event page range does not reconcile") + } + for _, cursor := range []*string{page.PreviousCursor, page.NextCursor, page.FirstCursor, page.LastCursor} { + if cursor != nil && len(*cursor) > 4096 { + return errors.New("event cursor is too large") + } + } + if page.Total == 0 && (page.RangeStart != 0 || page.RangeEnd != 0 || page.PreviousCursor != nil || page.NextCursor != nil || page.FirstCursor != nil || page.LastCursor != nil) { + return errors.New("empty event page cannot have a range or cursors") + } + if !validCoverage(page.Coverage) { + return errors.New("event page coverage does not reconcile") + } + if page.Total != page.Coverage.Indexed { + return errors.New("event page total does not match indexed coverage") + } + for index, item := range page.Items { + if !eventKinds[item.Kind] || len(item.Excerpt) > 512 || !validID(item.RevisionID) || item.Sequence == 0 || len(item.OccurredAt) > 128 { + return fmt.Errorf("invalid event item %d", index) + } + } + if !sort.SliceIsSorted(page.Items, func(i, j int) bool { + left, right := page.Items[i], page.Items[j] + if left.OccurredAt != right.OccurredAt { + return left.OccurredAt < right.OccurredAt + } + if left.Sequence != right.Sequence { + return left.Sequence < right.Sequence + } + return left.RevisionID < right.RevisionID + }) { + return errors.New("event items are not in canonical order") + } + return nil +} + +func ParseSummary(data []byte) (SessionSummary, error) { + var summary SessionSummary + if err := strictjson.Decode(data, &summary); err != nil { + return summary, err + } + if err := ValidateSummary(summary); err != nil { + return summary, err + } + return summary, nil +} + +func ParseEventPage(data []byte) (SessionEventPage, error) { + var page SessionEventPage + if err := strictjson.Decode(data, &page); err != nil { + return page, err + } + if err := ValidateEventPage(page); err != nil { + return page, err + } + return page, nil +} + +func RenderSummary(summary SessionSummary) ([]byte, error) { + normalizeSummary(&summary) + if err := ValidateSummary(summary); err != nil { + return nil, err + } + body, err := strictjson.Encode(summary) + if err != nil { + return nil, err + } + parsed, err := ParseSummary(body) + if err != nil || !reflect.DeepEqual(summary, parsed) { + return nil, errors.New("rendered session summary changed or failed validation") + } + return body, nil +} + +func RenderEventPage(page SessionEventPage) ([]byte, error) { + if page.Items == nil { + page.Items = []EventItem{} + } + if err := ValidateEventPage(page); err != nil { + return nil, err + } + body, err := strictjson.Encode(page) + if err != nil { + return nil, err + } + parsed, err := ParseEventPage(body) + if err != nil || !reflect.DeepEqual(page, parsed) { + return nil, errors.New("rendered event page changed or failed validation") + } + return body, nil +} + +func normalizeSummary(summary *SessionSummary) { + blocks := []*Block{&summary.PhaseBoundaries, &summary.KeyOperations, &summary.VerificationResults, &summary.UnresolvedQuestions} + for _, block := range blocks { + if block.Items == nil { + block.Items = []Entry{} + } + for i := range block.Items { + if block.Items[i].SourceRevisionIDs == nil { + block.Items[i].SourceRevisionIDs = []string{} + } + } + } + if summary.Errors.Items == nil { + summary.Errors.Items = []ErrorEntry{} + } + for i := range summary.Errors.Items { + if summary.Errors.Items[i].SourceRevisionIDs == nil { + summary.Errors.Items[i].SourceRevisionIDs = []string{} + } + } + if summary.Rules.DependencyDigests == nil { + summary.Rules.DependencyDigests = []string{} + } +} diff --git a/internal/inspect/validate_test.go b/internal/inspect/validate_test.go new file mode 100644 index 0000000..d3763b2 --- /dev/null +++ b/internal/inspect/validate_test.go @@ -0,0 +1,94 @@ +package inspect + +import ( + "github.com/neomei/SessionReviewer/internal/strictjson" + "os" + "testing" +) + +func TestRenderFrozenValidSummaryFixture(t *testing.T) { + b, e := os.ReadFile("../../testdata/contracts/v4/session-summary-v1.valid.json") + if e != nil { + t.Fatal(e) + } + var s SessionSummary + if e = strictjson.Decode(b, &s); e != nil { + t.Fatal(e) + } + if _, e = RenderSummary(s); e != nil { + t.Fatal(e) + } +} + +func TestParsersRejectFrozenInvalidFixtures(t *testing.T) { + for _, tc := range []struct { + name string + path string + parse func([]byte) error + }{ + {name: "summary", path: "../../testdata/contracts/v4/session-summary-v1.invalid.json", parse: func(b []byte) error { _, err := ParseSummary(b); return err }}, + {name: "event page", path: "../../testdata/contracts/v4/session-event-page-v1.invalid.json", parse: func(b []byte) error { _, err := ParseEventPage(b); return err }}, + } { + t.Run(tc.name, func(t *testing.T) { + b, err := os.ReadFile(tc.path) + if err != nil { + t.Fatal(err) + } + if err := tc.parse(b); err == nil { + t.Fatal("accepted frozen invalid fixture") + } + }) + } +} + +func TestValidateSummaryRejectsInvalidItemsRulesAndSort(t *testing.T) { + s := minimumSummary() + s.PhaseBoundaries = Block{Total: 1, Shown: 1, Items: []Entry{{OccurredAt: "2026-09-04T00:00:00Z", Sequence: 1, RevisionID: "revision-1", Text: "ok", SourceRevisionIDs: []string{"bad revision"}}}, Coverage: Coverage{Seen: 1, Indexed: 1}} + if err := ValidateSummary(s); err == nil { + t.Fatal("accepted invalid source revision ID") + } + s = minimumSummary() + s.Rules.DependencyDigests = []string{"bad"} + if err := ValidateSummary(s); err == nil { + t.Fatal("accepted invalid rule dependency digest") + } + s = minimumSummary() + s.PhaseBoundaries = Block{Total: 2, Shown: 2, Items: []Entry{{OccurredAt: "z", Sequence: 2, RevisionID: "revision-2", SourceRevisionIDs: []string{}}, {OccurredAt: "a", Sequence: 1, RevisionID: "revision-1", SourceRevisionIDs: []string{}}}, Coverage: Coverage{Seen: 2, Indexed: 2}} + if err := ValidateSummary(s); err == nil { + t.Fatal("accepted unstable summary item order") + } +} + +func TestValidateEventPageRejectsUnknownKindAndTooManyItems(t *testing.T) { + p := minimumEventPage() + p.Total, p.RangeEnd, p.Coverage = 1, 1, Coverage{Seen: 1, Indexed: 1} + p.Items = []EventItem{{Kind: "unknown", RevisionID: "revision-1", Sequence: 1}} + if err := ValidateEventPage(p); err == nil { + t.Fatal("accepted unknown event kind") + } + p = minimumEventPage() + p.Total, p.RangeEnd, p.Coverage = 101, 101, Coverage{Seen: 101, Indexed: 101} + p.Items = make([]EventItem, 101) + if err := ValidateEventPage(p); err == nil { + t.Fatal("accepted event page above 100 items") + } +} + +func minimumSummary() SessionSummary { + empty := Block{Items: []Entry{}} + return SessionSummary{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", Provider: "codex", SessionID: "s", GenerationID: "g", SessionViewDigest: "sha256:" + ones, PhaseBoundaries: empty, KeyOperations: empty, VerificationResults: empty, Errors: ErrorBlock{Items: []ErrorEntry{}}, UnresolvedQuestions: empty, Rules: Rules{RuleID: "rule", RuleVersion: "v1", DependencyDigests: []string{}}} +} + +func minimumEventPage() SessionEventPage { + return SessionEventPage{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", Provider: "codex", SessionID: "s", GenerationID: "g", SessionViewDigest: "sha256:" + ones, Items: []EventItem{}} +} + +func TestValidateEventPageRejectsCursorWhenTotalZero(t *testing.T) { + cursor := "cursor" + p := SessionEventPage{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", Provider: "codex", SessionID: "s", GenerationID: "g", SessionViewDigest: "sha256:" + ones, PreviousCursor: &cursor} + if err := ValidateEventPage(p); err == nil { + t.Fatal("accepted cursor for empty page") + } +} + +const ones = "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/internal/pricing/types.go b/internal/pricing/types.go new file mode 100644 index 0000000..ed20392 --- /dev/null +++ b/internal/pricing/types.go @@ -0,0 +1,94 @@ +package pricing + +type PriceStatus string + +const ( + PricePending PriceStatus = "pending" + PriceCurrent PriceStatus = "current" + PricePromotion PriceStatus = "promotion" + PriceStaleEstimate PriceStatus = "stale_estimate" + PriceManualSupplement PriceStatus = "manual_supplement" + PriceAmbiguous PriceStatus = "ambiguous" + PriceLegacyUnverified PriceStatus = "legacy_unverified" + PriceSuperseded PriceStatus = "superseded" +) + +type Rates struct { + Input *float64 `json:"input" required:"true" nullable:"true"` + CachedInput *float64 `json:"cached_input" required:"true" nullable:"true"` + CacheWriteInput *float64 `json:"cache_write_input" required:"true" nullable:"true"` + Output *float64 `json:"output" required:"true" nullable:"true"` + ReasoningOutput *float64 `json:"reasoning_output" required:"true" nullable:"true"` +} + +type Quantities struct { + Input uint64 `json:"input" required:"true"` + CachedInput uint64 `json:"cached_input" required:"true"` + CacheWriteInput uint64 `json:"cache_write_input" required:"true"` + Output uint64 `json:"output" required:"true"` + ReasoningOutput uint64 `json:"reasoning_output" required:"true"` +} + +type LineCosts struct { + Input *float64 `json:"input" required:"true" nullable:"true"` + CachedInput *float64 `json:"cached_input" required:"true" nullable:"true"` + CacheWriteInput *float64 `json:"cache_write_input" required:"true" nullable:"true"` + Output *float64 `json:"output" required:"true" nullable:"true"` + ReasoningOutput *float64 `json:"reasoning_output" required:"true" nullable:"true"` +} + +type Snapshot struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + SnapshotID string `json:"snapshot_id" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + UsageRecordDigest string `json:"usage_record_digest" required:"true"` + BillingHost string `json:"billing_host" required:"true"` + BilledModelID string `json:"billed_model_id" required:"true"` + BillingMode string `json:"billing_mode" required:"true"` + BillingRuleVersion string `json:"billing_rule_version" required:"true"` + Region *string `json:"region" required:"true" nullable:"true"` + PricedAt string `json:"priced_at" required:"true"` + CreatedAt string `json:"created_at" required:"true"` + Status PriceStatus `json:"status" required:"true"` + ModelPriceWatchListingID *string `json:"modelpricewatch_listing_id" required:"true" nullable:"true"` + SourceKind string `json:"source_kind" required:"true"` + SourceURL *string `json:"source_url" required:"true" nullable:"true"` + DetailURL *string `json:"detail_url" required:"true" nullable:"true"` + SourceLastUpdated *string `json:"source_last_updated" required:"true" nullable:"true"` + RetrievedAt *string `json:"retrieved_at" required:"true" nullable:"true"` + Promo bool `json:"promo" required:"true"` + PromoUntil *string `json:"promo_until" required:"true" nullable:"true"` + Rates Rates `json:"rates" required:"true"` + BillableQuantities Quantities `json:"billable_quantities" required:"true"` + LineCostsUSD LineCosts `json:"line_costs_usd" required:"true"` + MissingBillingDimensions []string `json:"missing_billing_dimensions" required:"true"` + KnownSubtotalUSD float64 `json:"known_subtotal_usd" required:"true"` + TotalCostUSD *float64 `json:"total_cost_usd" required:"true" nullable:"true"` + PricingComplete bool `json:"pricing_complete" required:"true"` + SupersedesSnapshotID *string `json:"supersedes_snapshot_id" required:"true" nullable:"true"` + AuditReason string `json:"audit_reason" required:"true"` +} + +type Supplement struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + UsageRecordDigest string `json:"usage_record_digest" required:"true"` + BillingHost string `json:"billing_host" required:"true"` + BilledModelID string `json:"billed_model_id" required:"true"` + BillingMode string `json:"billing_mode" required:"true"` + BillingRuleVersion string `json:"billing_rule_version" required:"true"` + Region *string `json:"region" required:"true" nullable:"true"` + EffectiveFrom string `json:"effective_from" required:"true"` + EffectiveUntil *string `json:"effective_until" required:"true" nullable:"true"` + Rates Rates `json:"rates" required:"true"` + SourceURL string `json:"source_url" required:"true"` + DetailURL *string `json:"detail_url" required:"true" nullable:"true"` + AuditReason string `json:"audit_reason" required:"true"` + SupersedesSnapshotID *string `json:"supersedes_snapshot_id" required:"true" nullable:"true"` +} diff --git a/internal/pricing/validate.go b/internal/pricing/validate.go new file mode 100644 index 0000000..6f831fc --- /dev/null +++ b/internal/pricing/validate.go @@ -0,0 +1,220 @@ +package pricing + +import ( + "errors" + "fmt" + "math" + "reflect" + "regexp" + "strings" + + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +const ( + maxID = 256 + maxText = 4096 + maxTimestamp = 128 + maxURL = 2048 +) + +func validID(value string) bool { return len(value) <= maxID && idRE.MatchString(value) } +func bounded(value string, maximum int, nonempty bool) bool { + return len(value) <= maximum && (!nonempty || value != "") +} +func validURL(value string) bool { + return bounded(value, maxURL, true) && len(value) > len("https://") && strings.HasPrefix(value, "https://") && !strings.ContainsAny(value, " \t\r\n") +} +func validOptional(value *string, maximum int) bool { return value == nil || len(*value) <= maximum } +func validOptionalURL(value *string) bool { return value == nil || validURL(*value) } +func validMoney(value *float64) bool { + return value == nil || (!math.IsNaN(*value) && !math.IsInf(*value, 0) && *value >= 0) +} + +func ValidateSnapshot(snapshot Snapshot) error { + if snapshot.SchemaVersion != 1 || snapshot.MinimumReaderVersion != "0.4.0" || + !validID(snapshot.SnapshotID) || !validID(snapshot.ProjectID) || !validID(snapshot.Provider) || !validID(snapshot.SessionID) || + !digestRE.MatchString(snapshot.UsageRecordDigest) || !bounded(snapshot.BillingHost, maxText, true) || + !bounded(snapshot.BilledModelID, maxText, true) || !bounded(snapshot.BillingMode, maxText, true) || + !validID(snapshot.BillingRuleVersion) || !validOptional(snapshot.Region, maxTimestamp) || + !bounded(snapshot.PricedAt, maxTimestamp, true) || !bounded(snapshot.CreatedAt, maxTimestamp, true) || + !validOptional(snapshot.ModelPriceWatchListingID, maxID) || !validOptionalURL(snapshot.SourceURL) || !validOptionalURL(snapshot.DetailURL) || + !validOptional(snapshot.SourceLastUpdated, maxTimestamp) || !validOptional(snapshot.RetrievedAt, maxTimestamp) || + !validOptional(snapshot.PromoUntil, maxTimestamp) || !validOptional(snapshot.SupersedesSnapshotID, maxID) || + !bounded(snapshot.AuditReason, maxText, true) { + return errors.New("invalid pricing snapshot fields") + } + switch snapshot.Status { + case PricePending, PriceCurrent, PricePromotion, PriceStaleEstimate, PriceManualSupplement, PriceAmbiguous, PriceLegacyUnverified, PriceSuperseded: + default: + return fmt.Errorf("invalid price status %q", snapshot.Status) + } + switch snapshot.SourceKind { + case "modelpricewatch", "official", "manual", "unresolved": + default: + return fmt.Errorf("invalid pricing source kind %q", snapshot.SourceKind) + } + if snapshot.SourceKind == "unresolved" { + if snapshot.SourceURL != nil || snapshot.PricingComplete { + return errors.New("unresolved pricing cannot carry resolved source evidence") + } + } else if snapshot.SourceURL == nil { + return errors.New("resolved pricing requires HTTPS source evidence") + } + if snapshot.SourceKind == "modelpricewatch" && (snapshot.ModelPriceWatchListingID == nil || *snapshot.ModelPriceWatchListingID == "" || snapshot.RetrievedAt == nil) { + return errors.New("modelpricewatch pricing requires listing and retrieval evidence") + } + amounts := []*float64{ + snapshot.Rates.Input, snapshot.Rates.CachedInput, snapshot.Rates.CacheWriteInput, snapshot.Rates.Output, snapshot.Rates.ReasoningOutput, + snapshot.LineCostsUSD.Input, snapshot.LineCostsUSD.CachedInput, snapshot.LineCostsUSD.CacheWriteInput, snapshot.LineCostsUSD.Output, snapshot.LineCostsUSD.ReasoningOutput, + snapshot.TotalCostUSD, + } + for _, value := range amounts { + if !validMoney(value) { + return errors.New("pricing amount must be finite and nonnegative") + } + } + if math.IsNaN(snapshot.KnownSubtotalUSD) || math.IsInf(snapshot.KnownSubtotalUSD, 0) || snapshot.KnownSubtotalUSD < 0 { + return errors.New("known subtotal must be finite and nonnegative") + } + if len(snapshot.MissingBillingDimensions) > 32 { + return errors.New("too many missing billing dimensions") + } + missing := map[string]bool{} + for _, dimension := range snapshot.MissingBillingDimensions { + if !bounded(dimension, maxText, true) || missing[dimension] { + return errors.New("invalid or duplicate missing billing dimension") + } + missing[dimension] = true + } + rates := []*float64{snapshot.Rates.Input, snapshot.Rates.CachedInput, snapshot.Rates.CacheWriteInput, snapshot.Rates.Output, snapshot.Rates.ReasoningOutput} + quantities := []uint64{snapshot.BillableQuantities.Input, snapshot.BillableQuantities.CachedInput, snapshot.BillableQuantities.CacheWriteInput, snapshot.BillableQuantities.Output, snapshot.BillableQuantities.ReasoningOutput} + costs := []*float64{snapshot.LineCostsUSD.Input, snapshot.LineCostsUSD.CachedInput, snapshot.LineCostsUSD.CacheWriteInput, snapshot.LineCostsUSD.Output, snapshot.LineCostsUSD.ReasoningOutput} + names := []string{"input", "cached_input", "cache_write_input", "output", "reasoning_output"} + subtotal := 0.0 + for i := range rates { + if quantities[i] > 0 && (rates[i] == nil || costs[i] == nil) && !missing[names[i]] { + return fmt.Errorf("unknown billed dimension %s is not reported", names[i]) + } + if rates[i] != nil && costs[i] != nil { + expected := float64(quantities[i]) * *rates[i] / 1_000_000 + if !nearlyEqual(*costs[i], expected) { + return fmt.Errorf("line cost %s does not match rate and quantity", names[i]) + } + subtotal += *costs[i] + } else if (rates[i] == nil) != (costs[i] == nil) { + return fmt.Errorf("rate and line cost availability disagree for %s", names[i]) + } + } + if !nearlyEqual(snapshot.KnownSubtotalUSD, subtotal) { + return errors.New("known subtotal does not equal known line costs") + } + if snapshot.PricingComplete { + for _, value := range append(rates, costs...) { + if value == nil { + return errors.New("complete pricing contains an unknown amount") + } + } + if snapshot.TotalCostUSD == nil || len(snapshot.MissingBillingDimensions) != 0 || !nearlyEqual(*snapshot.TotalCostUSD, snapshot.KnownSubtotalUSD) { + return errors.New("complete pricing total or missing dimensions do not reconcile") + } + } else if snapshot.TotalCostUSD != nil { + return errors.New("incomplete pricing total must be null") + } + return nil +} + +func ValidateSupplement(supplement Supplement) error { + if supplement.SchemaVersion != 1 || supplement.MinimumReaderVersion != "0.4.0" || + !validID(supplement.ProjectID) || !validID(supplement.Provider) || !validID(supplement.SessionID) || + !digestRE.MatchString(supplement.UsageRecordDigest) || !bounded(supplement.BillingHost, maxText, true) || + !bounded(supplement.BilledModelID, maxText, true) || !bounded(supplement.BillingMode, maxText, true) || + !validID(supplement.BillingRuleVersion) || !validOptional(supplement.Region, maxTimestamp) || + !bounded(supplement.EffectiveFrom, maxTimestamp, true) || !validOptional(supplement.EffectiveUntil, maxTimestamp) || + !validURL(supplement.SourceURL) || !validOptionalURL(supplement.DetailURL) || + !bounded(supplement.AuditReason, maxText, true) || !validOptional(supplement.SupersedesSnapshotID, maxID) { + return errors.New("invalid pricing supplement fields") + } + for _, value := range []*float64{supplement.Rates.Input, supplement.Rates.CachedInput, supplement.Rates.CacheWriteInput, supplement.Rates.Output, supplement.Rates.ReasoningOutput} { + if !validMoney(value) { + return errors.New("supplement rate must be finite and nonnegative") + } + } + return nil +} + +func nearlyEqual(left, right float64) bool { + delta := math.Abs(left - right) + return delta <= 1e-12*math.Max(1, math.Max(math.Abs(left), math.Abs(right))) +} + +func Parse(data []byte) (Snapshot, error) { + var snapshot Snapshot + if err := strictjson.Decode(data, &snapshot); err != nil { + return snapshot, err + } + if err := ValidateSnapshot(snapshot); err != nil { + return snapshot, err + } + return snapshot, nil +} + +func Render(snapshot Snapshot) ([]byte, error) { + if snapshot.MissingBillingDimensions == nil { + snapshot.MissingBillingDimensions = []string{} + } + if err := ValidateSnapshot(snapshot); err != nil { + return nil, err + } + return encodeRoundTrip(snapshot, ValidateSnapshot) +} + +func ParseSupplement(data []byte) (Supplement, error) { + var supplement Supplement + if err := strictjson.Decode(data, &supplement); err != nil { + return supplement, err + } + if err := ValidateSupplement(supplement); err != nil { + return supplement, err + } + return supplement, nil +} + +func RenderSupplement(supplement Supplement) ([]byte, error) { + if err := ValidateSupplement(supplement); err != nil { + return nil, err + } + body, err := strictjson.Encode(supplement) + if err != nil { + return nil, err + } + parsed, err := ParseSupplement(body) + if err != nil { + return nil, fmt.Errorf("rendered supplement failed validation: %w", err) + } + if !reflect.DeepEqual(supplement, parsed) { + return nil, errors.New("rendered supplement changed semantic value") + } + return body, nil +} + +func encodeRoundTrip(value Snapshot, validate func(Snapshot) error) ([]byte, error) { + body, err := strictjson.Encode(value) + if err != nil { + return nil, err + } + parsed, err := Parse(body) + if err != nil { + return nil, fmt.Errorf("rendered snapshot failed validation: %w", err) + } + if err := validate(parsed); err != nil { + return nil, err + } + if !reflect.DeepEqual(value, parsed) { + return nil, errors.New("rendered snapshot changed semantic value") + } + return body, nil +} diff --git a/internal/pricing/validate_test.go b/internal/pricing/validate_test.go new file mode 100644 index 0000000..569ad6c --- /dev/null +++ b/internal/pricing/validate_test.go @@ -0,0 +1,129 @@ +package pricing + +import ( + "math" + "os" + "testing" +) + +func TestParseFrozenValidFixture(t *testing.T) { + b, e := os.ReadFile("../../testdata/contracts/v4/pricing-snapshot-v1.valid.json") + if e != nil { + t.Fatal(e) + } + if _, e = Parse(b); e != nil { + t.Fatal(e) + } +} + +func TestValidateSnapshotRejectsIncompleteMarkedComplete(t *testing.T) { + u := "https://example.test" + s := Snapshot{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", SnapshotID: "s", ProjectID: "p", Provider: "codex", SessionID: "x", UsageRecordDigest: "sha256:" + ones, BillingHost: "h", BilledModelID: "m", BillingMode: "standard", BillingRuleVersion: "r", PricedAt: "now", CreatedAt: "now", Status: PriceCurrent, SourceKind: "official", SourceURL: &u, Rates: Rates{}, BillableQuantities: Quantities{}, LineCostsUSD: LineCosts{}, KnownSubtotalUSD: 0, PricingComplete: true} + if err := ValidateSnapshot(s); err == nil { + t.Fatal("accepted incomplete snapshot") + } +} + +func TestValidateSnapshotCompletenessAndFreePrice(t *testing.T) { + zero := 0.0 + ones := func() *float64 { v := 1.0; return &v } + tests := []struct { + name string + mutate func(*Snapshot) + }{ + {name: "nil rate", mutate: func(s *Snapshot) { s.Rates.Output = nil }}, + {name: "nil line cost", mutate: func(s *Snapshot) { s.LineCostsUSD.Output = nil }}, + {name: "nil total", mutate: func(s *Snapshot) { s.TotalCostUSD = nil }}, + {name: "missing dimension", mutate: func(s *Snapshot) { s.MissingBillingDimensions = []string{"output"} }}, + {name: "incomplete route evidence", mutate: func(s *Snapshot) { s.SourceURL = nil }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + s := completeSnapshot() + tc.mutate(&s) + if err := ValidateSnapshot(s); err == nil { + t.Fatal("accepted invalid complete snapshot") + } + }) + } + free := completeSnapshot() + free.Rates = Rates{&zero, &zero, &zero, &zero, &zero} + free.LineCostsUSD = LineCosts{&zero, &zero, &zero, &zero, &zero} + free.KnownSubtotalUSD = 0 + free.TotalCostUSD = &zero + if err := ValidateSnapshot(free); err != nil { + t.Fatalf("numeric zero must be a known free price: %v", err) + } + unknown := free + unknown.Rates.Input = nil + if err := ValidateSnapshot(unknown); err == nil { + t.Fatal("null rate was treated as free") + } + bad := completeSnapshot() + bad.TotalCostUSD = ones() + bad.KnownSubtotalUSD = math.Inf(1) + if err := ValidateSnapshot(bad); err == nil { + t.Fatal("accepted non-finite subtotal") + } +} + +func TestParseAndRenderPricingFixtureParity(t *testing.T) { + valid, err := os.ReadFile("../../testdata/contracts/v4/pricing-snapshot-v1.valid.json") + if err != nil { + t.Fatal(err) + } + got, err := Parse(valid) + if err != nil { + t.Fatal(err) + } + one, err := Render(got) + if err != nil { + t.Fatal(err) + } + two, err := Render(got) + if err != nil || string(one) != string(two) { + t.Fatalf("non-deterministic render: %v", err) + } + invalid, err := os.ReadFile("../../testdata/contracts/v4/pricing-snapshot-v1.invalid.json") + if err != nil { + t.Fatal(err) + } + if _, err := Parse(invalid); err == nil { + t.Fatal("accepted frozen invalid fixture") + } +} + +func TestPricingSupplementFixtureParityAndNullMeansUnknown(t *testing.T) { + valid, err := os.ReadFile("../../testdata/contracts/v4/pricing-supplement-v1.valid.json") + if err != nil { + t.Fatal(err) + } + supplement, err := ParseSupplement(valid) + if err != nil { + t.Fatal(err) + } + if supplement.Rates.Input == nil || *supplement.Rates.Input != 0 || supplement.Rates.CachedInput != nil { + t.Fatalf("free and unknown rates collapsed: %+v", supplement.Rates) + } + if _, err := RenderSupplement(supplement); err != nil { + t.Fatal(err) + } + invalid, err := os.ReadFile("../../testdata/contracts/v4/pricing-supplement-v1.invalid.json") + if err != nil { + t.Fatal(err) + } + if _, err := ParseSupplement(invalid); err == nil { + t.Fatal("accepted frozen invalid supplement fixture") + } +} + +func completeSnapshot() Snapshot { + v := 1.0 + five := 5.0 + u := "https://example.test/pricing" + return Snapshot{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", SnapshotID: "snapshot-1", ProjectID: "project-p", Provider: "codex", SessionID: "session-1", UsageRecordDigest: "sha256:" + ones, BillingHost: "api.example.test", BilledModelID: "model-1", BillingMode: "standard", BillingRuleVersion: "rules-v1", PricedAt: "2026-09-04T00:00:00Z", CreatedAt: "2026-09-04T00:00:00Z", Status: PriceCurrent, SourceKind: "official", SourceURL: &u, RetrievedAt: strptr("2026-09-04T00:00:00Z"), Rates: Rates{&v, &v, &v, &v, &v}, BillableQuantities: Quantities{1000000, 1000000, 1000000, 1000000, 1000000}, LineCostsUSD: LineCosts{&v, &v, &v, &v, &v}, MissingBillingDimensions: []string{}, KnownSubtotalUSD: 5, TotalCostUSD: &five, PricingComplete: true, AuditReason: "Exact route."} +} + +func strptr(value string) *string { return &value } + +const ones = "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/internal/reviewv4/codec.go b/internal/reviewv4/codec.go new file mode 100644 index 0000000..c31b57e --- /dev/null +++ b/internal/reviewv4/codec.go @@ -0,0 +1,192 @@ +package reviewv4 + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "reflect" + "strings" + "unicode/utf8" + + "github.com/neomei/SessionReviewer/internal/pricing" + "github.com/neomei/SessionReviewer/internal/sessionindex" + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +func DecodePresentation(data []byte) (Presentation, error) { + var presentation Presentation + if err := strictjson.Decode(data, &presentation); err != nil { + return presentation, err + } + if err := ValidatePresentation(presentation); err != nil { + return presentation, err + } + return presentation, nil +} + +func DecodeLedger(data []byte) (MachineLedger, error) { + var ledger MachineLedger + if err := strictjson.Decode(data, &ledger); err != nil { + return ledger, err + } + if err := ValidateLedger(ledger); err != nil { + return ledger, err + } + if !isZeroSHA(ledger.SyncHashes.LedgerSHA256) && CanonicalLedgerSHA256(ledger) != ledger.SyncHashes.LedgerSHA256 { + return ledger, errors.New("machine ledger self digest mismatch") + } + return ledger, nil +} + +func Parse(review, history, ledger []byte) (Accepted, error) { + return parse(review, history, ledger, nil) +} +func LoadProjection(review, history, ledger, index []byte) (Accepted, error) { + if len(index) == 0 { + return Accepted{}, errors.New("session index is required") + } + return parse(review, history, ledger, index) +} + +func parse(reviewBytes, historyBytes, ledgerBytes, indexBytes []byte) (Accepted, error) { + var accepted Accepted + var err error + accepted.Review, err = DecodePresentation(reviewBytes) + if err != nil { + return accepted, fmt.Errorf("review: %w", err) + } + if len(historyBytes) > strictjson.MaxBytes || !utf8.Valid(historyBytes) { + return accepted, errors.New("history exceeds the byte limit or is not UTF-8") + } + accepted.History = append([]byte(nil), historyBytes...) + accepted.Ledger, err = DecodeLedger(ledgerBytes) + if err != nil { + return accepted, fmt.Errorf("ledger: %w", err) + } + if err := ValidateAccepted(accepted); err != nil { + return accepted, err + } + if isZeroSHA(accepted.Ledger.SyncHashes.LedgerSHA256) { + return accepted, errors.New("machine ledger self digest is unset") + } + if accepted.Ledger.ReviewSHA256 != sha256Hex(reviewBytes) || accepted.Ledger.HistorySHA256 != sha256Hex(historyBytes) { + return accepted, errors.New("review or history content hash mismatch") + } + if len(indexBytes) > 0 { + accepted.SessionIndex, err = sessionindex.Parse(indexBytes) + if err != nil { + return accepted, fmt.Errorf("session index: %w", err) + } + index := accepted.SessionIndex + if index.Digest == "sha256:"+strings.Repeat("0", 64) { + return accepted, errors.New("session index digest is unset") + } + if index.ProjectID != accepted.Review.ProjectID || index.GenerationID != accepted.Review.GenerationID || index.ProjectViewDigest != accepted.Review.ProjectViewDigest || index.Digest != accepted.Ledger.SyncHashes.SessionIndexDigest { + return accepted, errors.New("session index identity, generation, or digest mismatch") + } + } + return accepted, nil +} + +func RenderLedger(ledger MachineLedger) ([]byte, error) { + normalizeLedger(&ledger) + ledger.SyncHashes.LedgerSHA256 = strings.Repeat("0", 64) + if err := ValidateLedger(ledger); err != nil { + return nil, err + } + ledger.SyncHashes.LedgerSHA256 = CanonicalLedgerSHA256(ledger) + body, err := strictjson.Encode(ledger) + if err != nil { + return nil, err + } + parsed, err := DecodeLedger(body) + if err != nil { + return nil, fmt.Errorf("rendered machine ledger failed validation: %w", err) + } + if !reflect.DeepEqual(ledger, parsed) { + return nil, errors.New("rendered machine ledger changed semantic value") + } + return body, nil +} + +func CanonicalLedgerSHA256(ledger MachineLedger) string { + ledger.SyncHashes.LedgerSHA256 = "" + type syncWithoutSelf struct { + ReviewSHA256 string `json:"review_sha256"` + HistorySHA256 string `json:"history_sha256"` + SessionIndexDigest string `json:"session_index_digest"` + } + type bodyWithoutSelf struct { + SchemaVersion int `json:"schema_version"` + MinimumReaderVersion string `json:"minimum_reader_version"` + MinimumWriterVersion string `json:"minimum_writer_version"` + ProjectID string `json:"project_id"` + GenerationID string `json:"generation_id"` + ProjectViewDigest string `json:"project_view_digest"` + AcceptedRevision int `json:"accepted_revision"` + ReviewSHA256 string `json:"review_sha256"` + HistorySHA256 string `json:"history_sha256"` + Accounting Accounting `json:"accounting"` + Sessions []LedgerSession `json:"sessions"` + HumanPatches []Patch `json:"human_patches"` + OrphanPatches []Patch `json:"orphan_patches"` + GeneratedBaselines []Baseline `json:"generated_baselines"` + PricingSnapshots any `json:"pricing_snapshots"` + CurrentPricingSnapshotIDs []string `json:"current_pricing_snapshot_ids"` + SyncHashes syncWithoutSelf `json:"sync_hashes"` + } + view := bodyWithoutSelf{ledger.SchemaVersion, ledger.MinimumReaderVersion, ledger.MinimumWriterVersion, ledger.ProjectID, ledger.GenerationID, ledger.ProjectViewDigest, ledger.AcceptedRevision, ledger.ReviewSHA256, ledger.HistorySHA256, ledger.Accounting, ledger.Sessions, ledger.HumanPatches, ledger.OrphanPatches, ledger.GeneratedBaselines, ledger.PricingSnapshots, ledger.CurrentPricingSnapshotIDs, syncWithoutSelf{ledger.SyncHashes.ReviewSHA256, ledger.SyncHashes.HistorySHA256, ledger.SyncHashes.SessionIndexDigest}} + body, err := strictjson.Encode(view) + if err != nil { + return "" + } + return sha256Hex(body) +} + +func normalizeLedger(ledger *MachineLedger) { + if ledger.Accounting.Models == nil { + ledger.Accounting.Models = []Model{} + } + if ledger.Sessions == nil { + ledger.Sessions = []LedgerSession{} + } + if ledger.HumanPatches == nil { + ledger.HumanPatches = []Patch{} + } + if ledger.OrphanPatches == nil { + ledger.OrphanPatches = []Patch{} + } + normalizePatches(ledger.HumanPatches) + normalizePatches(ledger.OrphanPatches) + if ledger.GeneratedBaselines == nil { + ledger.GeneratedBaselines = []Baseline{} + } + for index := range ledger.GeneratedBaselines { + if len(ledger.GeneratedBaselines[index].Values) == 0 { + ledger.GeneratedBaselines[index].Values = nil + } + } + if ledger.PricingSnapshots == nil { + ledger.PricingSnapshots = []pricing.Snapshot{} + } + for index := range ledger.PricingSnapshots { + if ledger.PricingSnapshots[index].MissingBillingDimensions == nil { + ledger.PricingSnapshots[index].MissingBillingDimensions = []string{} + } + } + if ledger.CurrentPricingSnapshotIDs == nil { + ledger.CurrentPricingSnapshotIDs = []string{} + } +} + +func normalizePatches(patches []Patch) { + for index := range patches { + if len(patches[index].Values) == 0 { + patches[index].Values = nil + } + } +} + +func sha256Hex(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } +func isZeroSHA(value string) bool { return value == strings.Repeat("0", 64) } diff --git a/internal/reviewv4/codec_test.go b/internal/reviewv4/codec_test.go new file mode 100644 index 0000000..83d213f --- /dev/null +++ b/internal/reviewv4/codec_test.go @@ -0,0 +1,235 @@ +package reviewv4 + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "strings" + "testing" + + "github.com/neomei/SessionReviewer/internal/sessionindex" +) + +func TestRenderFrozenValidLedgerFixture(t *testing.T) { + b, e := os.ReadFile("../../testdata/contracts/v4/machine-ledger-v4.valid.json") + if e != nil { + t.Fatal(e) + } + l, e := DecodeLedger(b) + if e != nil { + t.Fatal(e) + } + if _, e = RenderLedger(l); e != nil { + t.Fatal(e) + } +} + +func TestReviewParseRejectsUnknownFields(t *testing.T) { + if _, err := Parse([]byte(`{"unknown":true}`), []byte(`{}`), []byte(`{}`)); err == nil { + t.Fatal("accepted unknown review fields") + } +} + +func TestFrozenInvalidReviewAndLedgerFixturesAreRejected(t *testing.T) { + for _, tc := range []struct { + name string + path string + fn func([]byte) error + }{ + {name: "review", path: "../../testdata/contracts/v4/review-presentation-v4.invalid.json", fn: func(b []byte) error { _, err := DecodePresentation(b); return err }}, + {name: "ledger", path: "../../testdata/contracts/v4/machine-ledger-v4.invalid.json", fn: func(b []byte) error { _, err := DecodeLedger(b); return err }}, + } { + t.Run(tc.name, func(t *testing.T) { + b, err := os.ReadFile(tc.path) + if err != nil { + t.Fatal(err) + } + if err := tc.fn(b); err == nil { + t.Fatal("accepted frozen invalid fixture") + } + }) + } +} + +func TestValidatePresentationRejectsDecisionCycleAndBrokenGraph(t *testing.T) { + p := minimumPresentation() + p.Decisions = []Decision{minimumDecision("a", []string{"b"}), minimumDecision("b", []string{"a"})} + if err := ValidatePresentation(p); err == nil { + t.Fatal("accepted supersession cycle") + } + p.Decisions = []Decision{minimumDecision("old", nil), minimumDecision("new", []string{"old"})} + p.Decisions[0].Status = DecisionSuperseded + if err := ValidatePresentation(p); err != nil { + t.Fatal(err) + } + p.Decisions[1].Supersedes = nil + if err := ValidatePresentation(p); err == nil { + t.Fatal("accepted superseded decision without successor") + } +} + +func TestLoadProjectionEnforcesAllIdentityAndDigestBindings(t *testing.T) { + reviewFixture := mustRead(t, "../../testdata/contracts/v4/review-presentation-v4.valid.json") + indexFixture := mustRead(t, "../../testdata/contracts/v4/session-index-v1.valid.json") + review, err := DecodePresentation(reviewFixture) + if err != nil { + t.Fatal(err) + } + index, err := sessionindex.Parse(indexFixture) + if err != nil { + t.Fatal(err) + } + indexBytes, err := sessionindex.Render(index) + if err != nil { + t.Fatal(err) + } + index, err = sessionindex.Parse(indexBytes) + if err != nil { + t.Fatal(err) + } + ledgerFixture := mustRead(t, "../../testdata/contracts/v4/machine-ledger-v4.valid.json") + ledger, err := DecodeLedger(ledgerFixture) + if err != nil { + t.Fatal(err) + } + ledger.AcceptedRevision = review.Revision + history := reviewFixture + ledger.ReviewSHA256 = sha256hex(reviewFixture) + ledger.HistorySHA256 = sha256hex(history) + ledger.SyncHashes.ReviewSHA256 = ledger.ReviewSHA256 + ledger.SyncHashes.HistorySHA256 = ledger.HistorySHA256 + ledger.SyncHashes.SessionIndexDigest = index.Digest + ledger.SyncHashes.LedgerSHA256 = strings.Repeat("0", 64) + ledgerBytes, err := RenderLedger(ledger) + if err != nil { + t.Fatal(err) + } + accepted, err := LoadProjection(reviewFixture, history, ledgerBytes, indexBytes) + if err != nil { + t.Fatal(err) + } + if accepted.SessionIndex.ProjectID != review.ProjectID { + t.Fatalf("validated index missing from Accepted: %+v", accepted.SessionIndex) + } + if _, err := LoadProjection(reviewFixture, history, ledgerBytes, nil); err == nil { + t.Fatal("accepted projection without required session index") + } + + tests := []struct { + name string + mutate func(*Presentation, *MachineLedger, *sessionindex.Document, *[]byte) + }{ + {name: "project ID", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { l.ProjectID = "other" }}, + {name: "generation ID", mutate: func(_ *Presentation, _ *MachineLedger, i *sessionindex.Document, _ *[]byte) { i.GenerationID = "other" }}, + {name: "project view digest", mutate: func(p *Presentation, _ *MachineLedger, _ *sessionindex.Document, _ *[]byte) { + p.ProjectViewDigest = "sha256:" + strings.Repeat("9", 64) + }}, + {name: "review digest", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { + l.ReviewSHA256 = strings.Repeat("9", 64) + l.SyncHashes.ReviewSHA256 = l.ReviewSHA256 + }}, + {name: "history digest", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, h *[]byte) { + *h = append(*h, ' ') + l.HistorySHA256 = strings.Repeat("9", 64) + l.SyncHashes.HistorySHA256 = l.HistorySHA256 + }}, + {name: "index digest", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { + l.SyncHashes.SessionIndexDigest = "sha256:" + strings.Repeat("9", 64) + }}, + {name: "ledger top sync disagreement", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { + l.SyncHashes.ReviewSHA256 = strings.Repeat("8", 64) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + p := review + l := ledger + i := index + h := append([]byte(nil), history...) + tc.mutate(&p, &l, &i, &h) + rb, _ := jsonBytes(p) + ib, _ := sessionindex.Render(i) + lb, _ := RenderLedger(l) + if _, err := LoadProjection(rb, h, lb, ib); err == nil { + t.Fatal("accepted mismatched projection") + } + }) + } +} + +func TestParseAcceptsRawMarkdownHistoryAndRejectsInvalidUTF8(t *testing.T) { + review := mustRead(t, "../../testdata/contracts/v4/review-presentation-v4.valid.json") + ledgerBytes := mustRead(t, "../../testdata/contracts/v4/machine-ledger-v4.valid.json") + ledger, err := DecodeLedger(ledgerBytes) + if err != nil { + t.Fatal(err) + } + ledger.ReviewSHA256 = sha256hex(review) + ledger.AcceptedRevision = 1 + history := []byte("# 项目历史\n\n- 保留人类可读的里程碑。\n") + ledger.HistorySHA256 = sha256hex(history) + ledger.SyncHashes.ReviewSHA256 = ledger.ReviewSHA256 + ledger.SyncHashes.HistorySHA256 = ledger.HistorySHA256 + ledger.SyncHashes.LedgerSHA256 = strings.Repeat("0", 64) + ledgerBytes, _ = RenderLedger(ledger) + if accepted, err := Parse(review, history, ledgerBytes); err != nil || !bytes.Equal(accepted.History, history) { + t.Fatalf("raw markdown history rejected or changed: %v", err) + } + invalid := []byte{0xff} + ledger.HistorySHA256 = sha256hex(invalid) + ledger.SyncHashes.HistorySHA256 = ledger.HistorySHA256 + ledgerBytes, _ = RenderLedger(ledger) + if _, err := Parse(review, invalid, ledgerBytes); err == nil { + t.Fatal("accepted invalid UTF-8 history") + } +} + +func TestDecodeLedgerRejectsTamperedSelfDigest(t *testing.T) { + fixture := mustRead(t, "../../testdata/contracts/v4/machine-ledger-v4.valid.json") + ledger, err := DecodeLedger(fixture) + if err != nil { + t.Fatal(err) + } + body, err := RenderLedger(ledger) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatal(err) + } + raw["sync_hashes"].(map[string]any)["ledger_sha256"] = strings.Repeat("9", 64) + body, err = json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if _, err := DecodeLedger(body); err == nil { + t.Fatal("accepted tampered ledger self digest") + } +} + +func minimumPresentation() Presentation { + return Presentation{SchemaVersion: 4, MinimumReaderVersion: "0.4.0", MinimumWriterVersion: "0.4.0", ProjectID: "p", GenerationID: "g", ProjectViewDigest: "sha256:" + strings.Repeat("1", 64), CurrentState: CurrentState{}, Timeline: []Timeline{}, Decisions: []Decision{}, Risks: []Risk{}, OpenLoops: []OpenLoop{}, HumanPatches: []Patch{}, OrphanPatches: []Patch{}, GeneratedBaselines: []Baseline{}} +} + +func minimumDecision(id string, supersedes []string) Decision { + return Decision{ID: id, Kind: "decision", Status: DecisionActive, Supersedes: supersedes, MilestoneIDs: []string{}, SessionRefs: []SessionRef{}, Provenance: "human_created", Revision: 1} +} + +func mustRead(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} + +func sha256hex(b []byte) string { + s := sha256.Sum256(b) + return hex.EncodeToString(s[:]) +} + +func jsonBytes(value any) ([]byte, error) { return json.Marshal(value) } diff --git a/internal/reviewv4/types.go b/internal/reviewv4/types.go new file mode 100644 index 0000000..0c4e829 --- /dev/null +++ b/internal/reviewv4/types.go @@ -0,0 +1,182 @@ +package reviewv4 + +import ( + "github.com/neomei/SessionReviewer/internal/pricing" + "github.com/neomei/SessionReviewer/internal/sessionindex" +) + +type SessionKey struct{ Provider, SessionID string } +type ProcessingState string + +const ( + ProcessingComplete ProcessingState = "complete" + ProcessingPartial ProcessingState = "partial" + ProcessingError ProcessingState = "error" + ProcessingUnprocessed ProcessingState = "unprocessed" +) + +type DecisionStatus string + +const ( + DecisionActive DecisionStatus = "active" + DecisionSuperseded DecisionStatus = "superseded" + DecisionArchived DecisionStatus = "archived" +) + +type CandidateStatus string + +const ( + CandidatePending CandidateStatus = "pending" + CandidateConfirmed CandidateStatus = "confirmed" + CandidateIgnored CandidateStatus = "ignored" + CandidateNotDecision CandidateStatus = "not_decision" + CandidateStale CandidateStatus = "stale" +) + +type PriceStatus = pricing.PriceStatus + +const ( + PricePending = pricing.PricePending + PriceCurrent = pricing.PriceCurrent + PricePromotion = pricing.PricePromotion + PriceStaleEstimate = pricing.PriceStaleEstimate + PriceManualSupplement = pricing.PriceManualSupplement + PriceAmbiguous = pricing.PriceAmbiguous + PriceLegacyUnverified = pricing.PriceLegacyUnverified + PriceSuperseded = pricing.PriceSuperseded +) + +type CurrentState struct { + Goal string `json:"goal" required:"true"` + Stage string `json:"stage" required:"true"` + Status string `json:"status" required:"true"` + NextAction string `json:"next_action" required:"true"` + LastVerification string `json:"last_verification" required:"true"` +} +type Timeline struct { + ID string `json:"id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + OccurredAt string `json:"occurred_at" required:"true"` + Kind string `json:"kind" required:"true"` + Title string `json:"title" required:"true"` + Summary string `json:"summary" required:"true"` + DecisionIDs []string `json:"decision_ids" required:"true"` +} +type SessionRef struct { + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` +} +type Decision struct { + ID string `json:"id" required:"true"` + Kind string `json:"kind" required:"true"` + OccurredAt string `json:"occurred_at" required:"true"` + Title string `json:"title" required:"true"` + Rationale string `json:"rationale" required:"true"` + Impact string `json:"impact" required:"true"` + Status DecisionStatus `json:"status" required:"true"` + ReevaluateWhen string `json:"reevaluate_when" required:"true"` + Supersedes []string `json:"supersedes" required:"true"` + MilestoneIDs []string `json:"milestone_ids" required:"true"` + SessionRefs []SessionRef `json:"session_refs" required:"true"` + Provenance string `json:"provenance" required:"true"` + Pinned bool `json:"pinned" required:"true"` + Revision int `json:"revision" required:"true"` +} +type Risk struct { + ID string `json:"id" required:"true"` + Title string `json:"title" required:"true"` + Status string `json:"status" required:"true"` + Detail string `json:"detail" required:"true"` +} +type OpenLoop struct { + ID string `json:"id" required:"true"` + Title string `json:"title" required:"true"` + Status string `json:"status" required:"true"` + Question string `json:"question" required:"true"` + NextExperiment string `json:"next_experiment" required:"true"` + CompletionCriterion string `json:"completion_criterion" required:"true"` +} +type Patch struct { + EntityID string `json:"entity_id" required:"true"` + Field string `json:"field" required:"true"` + Operation string `json:"operation" required:"true"` + Value *string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` + BaseGeneratedHash string `json:"base_generated_hash" required:"true"` +} +type Baseline struct { + GenerationID string `json:"generation_id" required:"true"` + EntityID string `json:"entity_id" required:"true"` + Field string `json:"field" required:"true"` + Kind string `json:"kind" required:"true"` + Value *string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` + GeneratedHash string `json:"generated_hash" required:"true"` +} +type Presentation struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + MinimumWriterVersion string `json:"minimum_writer_version" required:"true"` + ProjectID string `json:"project_id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + ProjectViewDigest string `json:"project_view_digest" required:"true"` + Revision int `json:"revision" required:"true"` + CurrentState CurrentState `json:"current_state" required:"true"` + Timeline []Timeline `json:"timeline" required:"true"` + Decisions []Decision `json:"decisions" required:"true"` + Risks []Risk `json:"risks" required:"true"` + OpenLoops []OpenLoop `json:"open_loops" required:"true"` + HumanPatches []Patch `json:"human_patches" required:"true"` + OrphanPatches []Patch `json:"orphan_patches" required:"true"` + GeneratedBaselines []Baseline `json:"generated_baselines" required:"true"` +} +type Accounting struct { + TotalDurationMS uint64 `json:"total_duration_ms" required:"true"` + TotalTokens uint64 `json:"total_tokens" required:"true"` + TotalCostUSD *float64 `json:"total_cost_usd" required:"true" nullable:"true"` + Models []Model `json:"models" required:"true"` +} +type Model struct { + Model string `json:"model" required:"true"` + TotalTokens uint64 `json:"total_tokens" required:"true"` + TotalCostUSD *float64 `json:"total_cost_usd" required:"true" nullable:"true"` +} +type LedgerSession struct { + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + ProcessingState ProcessingState `json:"processing_state" required:"true"` + SourceAvailability string `json:"source_availability" required:"true"` + SessionViewDigest *string `json:"session_view_digest" required:"true" nullable:"true"` + UsageRecordDigest *string `json:"usage_record_digest" required:"true" nullable:"true"` +} +type SyncHashes struct { + ReviewSHA256 string `json:"review_sha256" required:"true"` + HistorySHA256 string `json:"history_sha256" required:"true"` + LedgerSHA256 string `json:"ledger_sha256" required:"true"` + SessionIndexDigest string `json:"session_index_digest" required:"true"` +} +type MachineLedger struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + MinimumWriterVersion string `json:"minimum_writer_version" required:"true"` + ProjectID string `json:"project_id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + ProjectViewDigest string `json:"project_view_digest" required:"true"` + AcceptedRevision int `json:"accepted_revision" required:"true"` + ReviewSHA256 string `json:"review_sha256" required:"true"` + HistorySHA256 string `json:"history_sha256" required:"true"` + Accounting Accounting `json:"accounting" required:"true"` + Sessions []LedgerSession `json:"sessions" required:"true"` + HumanPatches []Patch `json:"human_patches" required:"true"` + OrphanPatches []Patch `json:"orphan_patches" required:"true"` + GeneratedBaselines []Baseline `json:"generated_baselines" required:"true"` + PricingSnapshots []pricing.Snapshot `json:"pricing_snapshots" required:"true"` + CurrentPricingSnapshotIDs []string `json:"current_pricing_snapshot_ids" required:"true"` + SyncHashes SyncHashes `json:"sync_hashes" required:"true"` +} +type Accepted struct { + Review Presentation + History []byte + Ledger MachineLedger + SessionIndex sessionindex.Document +} diff --git a/internal/reviewv4/validate.go b/internal/reviewv4/validate.go new file mode 100644 index 0000000..7fc03b8 --- /dev/null +++ b/internal/reviewv4/validate.go @@ -0,0 +1,327 @@ +package reviewv4 + +import ( + "errors" + "fmt" + "math" + "regexp" + + "github.com/neomei/SessionReviewer/internal/pricing" +) + +var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +var shaRE = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } +func text(value string, maximum int) bool { return len(value) <= maximum } +func optionalText(value *string, maximum int) bool { return value == nil || text(*value, maximum) } +func optionalDigest(value *string) bool { return value == nil || digestRE.MatchString(*value) } + +func ValidatePresentation(p Presentation) error { + if p.SchemaVersion != 4 || p.MinimumReaderVersion != "0.4.0" || p.MinimumWriterVersion != "0.4.0" || !validID(p.ProjectID) || !validID(p.GenerationID) || !digestRE.MatchString(p.ProjectViewDigest) || p.Revision < 0 { + return errors.New("invalid review presentation metadata") + } + for _, value := range []string{p.CurrentState.Goal, p.CurrentState.Stage, p.CurrentState.Status, p.CurrentState.NextAction, p.CurrentState.LastVerification} { + if !text(value, 16384) { + return errors.New("current state text exceeds limit") + } + } + if len(p.Timeline) > 65536 || len(p.Decisions) > 65536 || len(p.Risks) > 65536 || len(p.OpenLoops) > 65536 || len(p.HumanPatches) > 65536 || len(p.OrphanPatches) > 65536 || len(p.GeneratedBaselines) > 65536 { + return errors.New("review presentation exceeds array limit") + } + timelineIDs := map[string]bool{} + for i, timeline := range p.Timeline { + if !validID(timeline.ID) || timeline.GenerationID != p.GenerationID || len(timeline.OccurredAt) > 128 || !validID(timeline.Kind) || !text(timeline.Title, 16384) || !text(timeline.Summary, 16384) || len(timeline.DecisionIDs) > 256 || timelineIDs[timeline.ID] { + return fmt.Errorf("invalid or duplicate timeline %d", i) + } + timelineIDs[timeline.ID] = true + if err := uniqueIDs(timeline.DecisionIDs); err != nil { + return err + } + } + decisions := map[string]Decision{} + for i, decision := range p.Decisions { + if !validID(decision.ID) || len(decision.OccurredAt) > 128 || !text(decision.Title, 16384) || !text(decision.Rationale, 16384) || !text(decision.Impact, 16384) || !text(decision.ReevaluateWhen, 16384) || decision.Revision < 1 || len(decision.Supersedes) > 256 || len(decision.MilestoneIDs) > 256 || len(decision.SessionRefs) > 256 { + return fmt.Errorf("invalid decision %d", i) + } + if _, exists := decisions[decision.ID]; exists { + return fmt.Errorf("duplicate decision %q", decision.ID) + } + switch decision.Kind { + case "decision", "agreement": + default: + return errors.New("invalid decision kind") + } + switch decision.Status { + case DecisionActive, DecisionSuperseded, DecisionArchived: + default: + return errors.New("invalid decision status") + } + switch decision.Provenance { + case "human_created", "migrated", "ai_candidate_confirmed": + default: + return errors.New("invalid decision provenance") + } + if err := uniqueIDs(decision.Supersedes); err != nil { + return err + } + if err := uniqueIDs(decision.MilestoneIDs); err != nil { + return err + } + refs := map[SessionKey]bool{} + for _, ref := range decision.SessionRefs { + key := SessionKey{ref.Provider, ref.SessionID} + if !validID(ref.Provider) || !validID(ref.SessionID) || refs[key] { + return errors.New("invalid or duplicate decision session reference") + } + refs[key] = true + } + decisions[decision.ID] = decision + } + successors := map[string]int{} + for _, decision := range p.Decisions { + for _, target := range decision.Supersedes { + if target == decision.ID { + return errors.New("decision cannot supersede itself") + } + if _, exists := decisions[target]; !exists { + return fmt.Errorf("decision %q supersedes missing %q", decision.ID, target) + } + successors[target]++ + } + } + if decisionCycle(decisions) { + return errors.New("decision supersession graph contains cycle") + } + for id, decision := range decisions { + if decision.Status == DecisionSuperseded && successors[id] == 0 { + return fmt.Errorf("superseded decision %q has no successor", id) + } + } + for _, timeline := range p.Timeline { + for _, id := range timeline.DecisionIDs { + if _, exists := decisions[id]; !exists { + return fmt.Errorf("timeline references missing decision %q", id) + } + } + } + for _, decision := range p.Decisions { + for _, id := range decision.MilestoneIDs { + if !timelineIDs[id] { + return fmt.Errorf("decision references missing milestone %q", id) + } + } + } + riskIDs := map[string]bool{} + for _, risk := range p.Risks { + if !validID(risk.ID) || !text(risk.Title, 16384) || !text(risk.Status, 16384) || !text(risk.Detail, 16384) { + return errors.New("invalid risk") + } + if riskIDs[risk.ID] { + return errors.New("duplicate risk") + } + riskIDs[risk.ID] = true + } + loopIDs := map[string]bool{} + for _, loop := range p.OpenLoops { + if !validID(loop.ID) || !text(loop.Title, 16384) || !text(loop.Status, 16384) || !text(loop.Question, 16384) || !text(loop.NextExperiment, 16384) || !text(loop.CompletionCriterion, 16384) { + return errors.New("invalid open loop") + } + if loopIDs[loop.ID] { + return errors.New("duplicate open loop") + } + loopIDs[loop.ID] = true + } + for _, patch := range append(append([]Patch{}, p.HumanPatches...), p.OrphanPatches...) { + if err := validatePatch(patch); err != nil { + return err + } + } + for _, baseline := range p.GeneratedBaselines { + if !validID(baseline.GenerationID) || !validID(baseline.EntityID) || !validID(baseline.Field) || !validID(baseline.Kind) || !shaRE.MatchString(baseline.GeneratedHash) || !optionalText(baseline.Value, 16384) || len(baseline.Values) > 256 { + return errors.New("invalid generated baseline") + } + for _, value := range baseline.Values { + if !text(value, 16384) { + return errors.New("baseline value exceeds limit") + } + } + } + return nil +} + +func uniqueIDs(values []string) error { + seen := map[string]bool{} + for _, value := range values { + if !validID(value) || seen[value] { + return errors.New("invalid or duplicate ID") + } + seen[value] = true + } + return nil +} + +func validatePatch(patch Patch) error { + if !validID(patch.EntityID) || !validID(patch.Field) || !shaRE.MatchString(patch.BaseGeneratedHash) || !optionalText(patch.Value, 16384) || len(patch.Values) > 256 { + return errors.New("invalid presentation patch") + } + switch patch.Operation { + case "set", "suppress", "restore_default": + default: + return errors.New("invalid patch operation") + } + for _, value := range patch.Values { + if !text(value, 16384) { + return errors.New("patch value exceeds limit") + } + } + return nil +} + +func decisionCycle(decisions map[string]Decision) bool { + state := map[string]uint8{} + var visit func(string) bool + visit = func(id string) bool { + if state[id] == 1 { + return true + } + if state[id] == 2 { + return false + } + state[id] = 1 + for _, next := range decisions[id].Supersedes { + if visit(next) { + return true + } + } + state[id] = 2 + return false + } + for id := range decisions { + if visit(id) { + return true + } + } + return false +} + +func ValidateLedger(l MachineLedger) error { + if l.SchemaVersion != 4 || l.MinimumReaderVersion != "0.4.0" || l.MinimumWriterVersion != "0.4.0" || !validID(l.ProjectID) || !validID(l.GenerationID) || !digestRE.MatchString(l.ProjectViewDigest) || l.AcceptedRevision < 0 || !shaRE.MatchString(l.ReviewSHA256) || !shaRE.MatchString(l.HistorySHA256) { + return errors.New("invalid machine ledger metadata") + } + if len(l.Sessions) > 65536 || len(l.HumanPatches) > 65536 || len(l.OrphanPatches) > 65536 || len(l.GeneratedBaselines) > 65536 || len(l.PricingSnapshots) > 65536 || len(l.CurrentPricingSnapshotIDs) > 65536 || len(l.Accounting.Models) > 256 { + return errors.New("machine ledger exceeds array limit") + } + if !money(l.Accounting.TotalCostUSD) { + return errors.New("invalid aggregate cost") + } + incompletePricing := false + pricingByID := map[string]pricing.Snapshot{} + for _, snapshot := range l.PricingSnapshots { + if err := pricing.ValidateSnapshot(snapshot); err != nil { + return err + } + if snapshot.ProjectID != l.ProjectID { + return errors.New("pricing snapshot project mismatch") + } + if _, exists := pricingByID[snapshot.SnapshotID]; exists { + return errors.New("duplicate pricing snapshot") + } + pricingByID[snapshot.SnapshotID] = snapshot + incompletePricing = incompletePricing || !snapshot.PricingComplete + } + if incompletePricing && l.Accounting.TotalCostUSD != nil { + return errors.New("aggregate price must be null when a snapshot is incomplete") + } + modelNames := map[string]bool{} + var modelTokens uint64 + modelCostsComplete := true + modelCost := 0.0 + for _, model := range l.Accounting.Models { + if !text(model.Model, 16384) || !money(model.TotalCostUSD) || modelNames[model.Model] { + return errors.New("invalid or duplicate accounting model") + } + modelNames[model.Model] = true + if ^uint64(0)-modelTokens < model.TotalTokens { + return errors.New("accounting model tokens overflow") + } + modelTokens += model.TotalTokens + if model.TotalCostUSD == nil { + modelCostsComplete = false + } else { + modelCost += *model.TotalCostUSD + } + } + if len(l.Accounting.Models) > 0 && modelTokens != l.Accounting.TotalTokens { + return errors.New("accounting token total does not reconcile") + } + if len(l.Accounting.Models) > 0 && modelCostsComplete && l.Accounting.TotalCostUSD != nil && !nearlyEqual(*l.Accounting.TotalCostUSD, modelCost) { + return errors.New("accounting cost total does not reconcile") + } + keys := map[SessionKey]bool{} + for _, session := range l.Sessions { + key := SessionKey{session.Provider, session.SessionID} + if !validID(session.Provider) || !validID(session.SessionID) || keys[key] || !optionalDigest(session.SessionViewDigest) || !optionalDigest(session.UsageRecordDigest) { + return errors.New("invalid or duplicate ledger session") + } + keys[key] = true + switch session.ProcessingState { + case ProcessingComplete, ProcessingPartial, ProcessingError, ProcessingUnprocessed: + default: + return errors.New("invalid ledger processing state") + } + switch session.SourceAvailability { + case "available", "unavailable": + default: + return errors.New("invalid ledger source availability") + } + } + for _, patch := range append(append([]Patch{}, l.HumanPatches...), l.OrphanPatches...) { + if err := validatePatch(patch); err != nil { + return err + } + } + for _, baseline := range l.GeneratedBaselines { + if !validID(baseline.GenerationID) || !validID(baseline.EntityID) || !validID(baseline.Field) || !validID(baseline.Kind) || !shaRE.MatchString(baseline.GeneratedHash) || !optionalText(baseline.Value, 16384) || len(baseline.Values) > 256 { + return errors.New("invalid ledger baseline") + } + } + seenCurrent := map[string]bool{} + for _, id := range l.CurrentPricingSnapshotIDs { + snapshot, exists := pricingByID[id] + if !validID(id) || !exists || seenCurrent[id] || snapshot.Status == pricing.PriceSuperseded { + return errors.New("invalid current pricing snapshot reference") + } + seenCurrent[id] = true + } + if !shaRE.MatchString(l.SyncHashes.ReviewSHA256) || !shaRE.MatchString(l.SyncHashes.HistorySHA256) || !shaRE.MatchString(l.SyncHashes.LedgerSHA256) || !digestRE.MatchString(l.SyncHashes.SessionIndexDigest) { + return errors.New("invalid synchronization hashes") + } + if l.SyncHashes.ReviewSHA256 != l.ReviewSHA256 || l.SyncHashes.HistorySHA256 != l.HistorySHA256 { + return errors.New("top-level and synchronization hashes disagree") + } + return nil +} + +func money(value *float64) bool { + return value == nil || (!math.IsNaN(*value) && !math.IsInf(*value, 0) && *value >= 0) +} + +func nearlyEqual(left, right float64) bool { + delta := math.Abs(left - right) + return delta <= 1e-12*math.Max(1, math.Max(math.Abs(left), math.Abs(right))) +} + +func ValidateAccepted(a Accepted) error { + if err := ValidatePresentation(a.Review); err != nil { + return err + } + if err := ValidateLedger(a.Ledger); err != nil { + return err + } + if a.Review.ProjectID != a.Ledger.ProjectID || a.Review.GenerationID != a.Ledger.GenerationID || a.Review.ProjectViewDigest != a.Ledger.ProjectViewDigest || a.Review.Revision != a.Ledger.AcceptedRevision { + return errors.New("review and ledger identity, generation, digest, or revision mismatch") + } + return nil +} diff --git a/internal/sessionindex/types.go b/internal/sessionindex/types.go new file mode 100644 index 0000000..0f8abda --- /dev/null +++ b/internal/sessionindex/types.go @@ -0,0 +1,82 @@ +package sessionindex + +const SortVersion = "started-at-desc-null-last-provider-session-v1" + +type ProcessingState string + +const ( + ProcessingComplete ProcessingState = "complete" + ProcessingPartial ProcessingState = "partial" + ProcessingError ProcessingState = "error" + ProcessingUnprocessed ProcessingState = "unprocessed" +) + +type SessionKey struct { + Provider string + SessionID string +} + +type Coverage struct { + Seen uint64 `json:"seen" required:"true"` + Indexed uint64 `json:"indexed" required:"true"` + Collapsed uint64 `json:"collapsed" required:"true"` + Unprojected uint64 `json:"unprojected" required:"true"` + Undecodable uint64 `json:"undecodable" required:"true"` + Truncated uint64 `json:"truncated" required:"true"` +} + +type IndexCoverage struct { + Total uint64 `json:"total" required:"true"` + Complete uint64 `json:"complete" required:"true"` + Partial uint64 `json:"partial" required:"true"` + Error uint64 `json:"error" required:"true"` + Unprocessed uint64 `json:"unprocessed" required:"true"` + SourceAvailable uint64 `json:"source_available" required:"true"` + SourceUnavailable uint64 `json:"source_unavailable" required:"true"` + StartedAtKnown uint64 `json:"started_at_known" required:"true"` + EndedAtKnown uint64 `json:"ended_at_known" required:"true"` + UsageKnown uint64 `json:"usage_known" required:"true"` +} + +type FactCounts struct { + FileChange uint64 `json:"file_change" required:"true"` + Command uint64 `json:"command" required:"true"` + Verification uint64 `json:"verification" required:"true"` + Error uint64 `json:"error" required:"true"` + Artifact uint64 `json:"artifact" required:"true"` +} + +type Entry struct { + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + ProcessingState ProcessingState `json:"processing_state" required:"true"` + StateReasonCodes []string `json:"state_reason_codes" required:"true"` + SourceAvailability string `json:"source_availability" required:"true"` + SourceTerminalState *string `json:"source_terminal_state" required:"true" nullable:"true"` + StartedAt string `json:"started_at" required:"true"` + EndedAt string `json:"ended_at" required:"true"` + DurationMS *uint64 `json:"duration_ms" required:"true" nullable:"true"` + WarningCount uint64 `json:"warning_count" required:"true"` + RecordCount *uint64 `json:"record_count" required:"true" nullable:"true"` + IndexedEventCount uint64 `json:"indexed_event_count" required:"true"` + Coverage Coverage `json:"coverage" required:"true"` + FactCounts FactCounts `json:"fact_counts" required:"true"` + SessionViewDigest *string `json:"session_view_digest" required:"true" nullable:"true"` + UsageRecordDigest *string `json:"usage_record_digest" required:"true" nullable:"true"` + SummaryDigest *string `json:"summary_digest" required:"true" nullable:"true"` + LastSeenGenerationID *string `json:"last_seen_generation_id" required:"true" nullable:"true"` + LastSuccessfulGenerationID *string `json:"last_successful_generation_id" required:"true" nullable:"true"` +} + +type Document struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + Digest string `json:"digest" required:"true"` + ProjectID string `json:"project_id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + ProjectViewDigest string `json:"project_view_digest" required:"true"` + GeneratedAt string `json:"generated_at" required:"true"` + SortVersion string `json:"sort_version" required:"true"` + Coverage IndexCoverage `json:"coverage" required:"true"` + Sessions []Entry `json:"sessions" required:"true"` +} diff --git a/internal/sessionindex/validate.go b/internal/sessionindex/validate.go new file mode 100644 index 0000000..c29d79d --- /dev/null +++ b/internal/sessionindex/validate.go @@ -0,0 +1,187 @@ +package sessionindex + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "reflect" + "regexp" + "sort" + "strings" + + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +var stateReasons = map[string]bool{ + "not_discovered": true, "duplicate_candidate": true, "freeze_terminal": true, + "malformed_source_records": true, "unsupported_source_records": true, + "source_missing": true, "source_unreadable": true, "source_ambiguous": true, + "source_unsupported": true, "source_unavailable": true, "partial_observations": true, + "unprojected_facts": true, "undecodable_facts": true, "scan_cancelled": true, +} + +func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } +func validOptional(value *string, maximum int) bool { return value == nil || len(*value) <= maximum } +func validDigest(value *string) bool { return value == nil || digestRE.MatchString(*value) } +func reconcileCoverage(coverage Coverage) bool { + return coverage.Indexed+coverage.Collapsed+coverage.Unprojected+coverage.Undecodable+coverage.Truncated == coverage.Seen +} + +func Validate(document Document) error { + if document.SchemaVersion != 1 || document.MinimumReaderVersion != "0.4.0" || + !validID(document.ProjectID) || !validID(document.GenerationID) || + !digestRE.MatchString(document.Digest) || !digestRE.MatchString(document.ProjectViewDigest) || + document.GeneratedAt == "" || len(document.GeneratedAt) > 128 || document.SortVersion != SortVersion { + return errors.New("invalid session index metadata") + } + if len(document.Sessions) > 65536 { + return errors.New("too many sessions") + } + calculated := IndexCoverage{Total: uint64(len(document.Sessions))} + keys := map[SessionKey]bool{} + for index, entry := range document.Sessions { + key := SessionKey{Provider: entry.Provider, SessionID: entry.SessionID} + if !validID(entry.Provider) || !validID(entry.SessionID) || keys[key] { + return fmt.Errorf("invalid or duplicate session identity at %d", index) + } + keys[key] = true + switch entry.ProcessingState { + case ProcessingComplete: + calculated.Complete++ + case ProcessingPartial: + calculated.Partial++ + case ProcessingError: + calculated.Error++ + case ProcessingUnprocessed: + calculated.Unprocessed++ + default: + return fmt.Errorf("invalid processing state at %d", index) + } + switch entry.SourceAvailability { + case "available": + calculated.SourceAvailable++ + case "unavailable": + calculated.SourceUnavailable++ + default: + return fmt.Errorf("invalid source availability at %d", index) + } + if entry.StartedAt == "" || len(entry.StartedAt) > 128 || entry.EndedAt == "" || len(entry.EndedAt) > 128 || !validOptional(entry.SourceTerminalState, 64) { + return fmt.Errorf("invalid session timestamps at %d", index) + } + calculated.StartedAtKnown++ + calculated.EndedAtKnown++ + if entry.UsageRecordDigest != nil { + calculated.UsageKnown++ + } + if len(entry.StateReasonCodes) > 64 { + return fmt.Errorf("too many state reason codes at %d", index) + } + seenReasons := map[string]bool{} + for _, reason := range entry.StateReasonCodes { + if !stateReasons[reason] || seenReasons[reason] { + return fmt.Errorf("invalid or duplicate state reason %q", reason) + } + seenReasons[reason] = true + } + if !reconcileCoverage(entry.Coverage) || entry.IndexedEventCount != entry.Coverage.Indexed { + return fmt.Errorf("session %s coverage does not reconcile", entry.SessionID) + } + if !validDigest(entry.SessionViewDigest) || !validDigest(entry.UsageRecordDigest) || !validDigest(entry.SummaryDigest) || + !validOptional(entry.LastSeenGenerationID, 256) || !validOptional(entry.LastSuccessfulGenerationID, 256) { + return fmt.Errorf("session %s has an invalid digest or generation reference", entry.SessionID) + } + } + if calculated != document.Coverage || document.Coverage.Complete+document.Coverage.Partial+document.Coverage.Error+document.Coverage.Unprocessed != document.Coverage.Total || document.Coverage.SourceAvailable+document.Coverage.SourceUnavailable != document.Coverage.Total { + return errors.New("index coverage does not reconcile") + } + if !sort.SliceIsSorted(document.Sessions, func(i, j int) bool { + return less(document.Sessions[i], document.Sessions[j]) + }) { + return errors.New("sessions are not in canonical order") + } + return nil +} + +func less(left, right Entry) bool { + if left.StartedAt != right.StartedAt { + return left.StartedAt > right.StartedAt + } + if left.Provider != right.Provider { + return left.Provider < right.Provider + } + return left.SessionID < right.SessionID +} + +func Parse(data []byte) (Document, error) { + var document Document + if err := strictjson.Decode(data, &document); err != nil { + return document, err + } + if err := Validate(document); err != nil { + return document, err + } + if !isZeroDigest(document.Digest) && CanonicalDigest(document) != document.Digest { + return document, errors.New("session index digest mismatch") + } + return document, nil +} + +func Render(document Document) ([]byte, error) { + normalize(&document) + document.Digest = zeroDigest() + if err := Validate(document); err != nil { + return nil, err + } + document.Digest = CanonicalDigest(document) + body, err := strictjson.Encode(document) + if err != nil { + return nil, err + } + parsed, err := Parse(body) + if err != nil { + return nil, fmt.Errorf("rendered session index failed validation: %w", err) + } + if !reflect.DeepEqual(document, parsed) { + return nil, errors.New("rendered session index changed semantic value") + } + return body, nil +} + +func CanonicalDigest(document Document) string { + document.Digest = "" + view := struct { + SchemaVersion int `json:"schema_version"` + MinimumReaderVersion string `json:"minimum_reader_version"` + ProjectID string `json:"project_id"` + GenerationID string `json:"generation_id"` + ProjectViewDigest string `json:"project_view_digest"` + GeneratedAt string `json:"generated_at"` + SortVersion string `json:"sort_version"` + Coverage IndexCoverage `json:"coverage"` + Sessions []Entry `json:"sessions"` + }{document.SchemaVersion, document.MinimumReaderVersion, document.ProjectID, document.GenerationID, document.ProjectViewDigest, document.GeneratedAt, document.SortVersion, document.Coverage, document.Sessions} + body, err := strictjson.Encode(view) + if err != nil { + return "" + } + digest := sha256.Sum256(body) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func normalize(document *Document) { + if document.Sessions == nil { + document.Sessions = []Entry{} + } + for index := range document.Sessions { + if document.Sessions[index].StateReasonCodes == nil { + document.Sessions[index].StateReasonCodes = []string{} + } + } +} + +func zeroDigest() string { return "sha256:" + strings.Repeat("0", 64) } +func isZeroDigest(value string) bool { return value == zeroDigest() } diff --git a/internal/sessionindex/validate_test.go b/internal/sessionindex/validate_test.go new file mode 100644 index 0000000..344184f --- /dev/null +++ b/internal/sessionindex/validate_test.go @@ -0,0 +1,108 @@ +package sessionindex + +import ( + "os" + "testing" +) + +func TestParseFrozenValidFixture(t *testing.T) { + b, e := os.ReadFile("../../testdata/contracts/v4/session-index-v1.valid.json") + if e != nil { + t.Fatal(e) + } + if _, e = Parse(b); e != nil { + t.Fatal(e) + } +} + +func TestValidateIdentityUsesProviderAndSessionIDPair(t *testing.T) { + d := minimumDocument() + d.Sessions = []Entry{{Provider: "claude", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: "now", EndedAt: "now"}, {Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: "now", EndedAt: "now"}} + d.Coverage.Total = 2 + d.Coverage.Complete = 2 + d.Coverage.SourceAvailable = 2 + d.Coverage.StartedAtKnown = 2 + d.Coverage.EndedAtKnown = 2 + if err := Validate(d); err != nil { + t.Fatal(err) + } + d.Sessions[0].Provider = "codex" + if err := Validate(d); err == nil { + t.Fatal("accepted duplicate provider/session identity") + } +} + +func TestValidateCoverageAndDigest(t *testing.T) { + d := minimumDocument() + d.Coverage.Total = 1 + if err := Validate(d); err == nil { + t.Fatal("accepted coverage mismatch") + } +} + +func TestParseRejectsFrozenInvalidFixture(t *testing.T) { + b, err := os.ReadFile("../../testdata/contracts/v4/session-index-v1.invalid.json") + if err != nil { + t.Fatal(err) + } + if _, err := Parse(b); err == nil { + t.Fatal("accepted frozen invalid fixture") + } +} + +func TestSessionIndexRejectsInvalidStateReasonAndDigestFields(t *testing.T) { + tests := []struct { + name string + mutate func(*Entry) + }{ + {name: "unknown state reason", mutate: func(e *Entry) { e.StateReasonCodes = []string{"not-in-contract"} }}, + {name: "usage digest", mutate: func(e *Entry) { e.UsageRecordDigest = strptr("bad") }}, + {name: "summary digest", mutate: func(e *Entry) { e.SummaryDigest = strptr("bad") }}, + {name: "last seen generation too long", mutate: func(e *Entry) { e.LastSeenGenerationID = strptr(string(make([]byte, 257))) }}, + {name: "last successful generation too long", mutate: func(e *Entry) { e.LastSuccessfulGenerationID = strptr(string(make([]byte, 257))) }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + d := oneSessionDocument() + tc.mutate(&d.Sessions[0]) + if err := Validate(d); err == nil { + t.Fatal("accepted invalid session field") + } + }) + } +} + +func TestRenderCalculatesDigestAndIsDeterministic(t *testing.T) { + d := minimumDocument() + one, err := Render(d) + if err != nil { + t.Fatal(err) + } + two, err := Render(d) + if err != nil || string(one) != string(two) { + t.Fatalf("non-deterministic render: %v", err) + } + parsed, err := Parse(one) + if err != nil { + t.Fatal(err) + } + if parsed.Digest == "sha256:"+zeros || parsed.Digest != CanonicalDigest(parsed) { + t.Fatalf("digest=%q", parsed.Digest) + } +} + +func oneSessionDocument() Document { + d := minimumDocument() + d.Sessions = []Entry{{Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, StateReasonCodes: []string{}, SourceAvailability: "available", StartedAt: "now", EndedAt: "now", Coverage: Coverage{}}} + d.Coverage = IndexCoverage{Total: 1, Complete: 1, SourceAvailable: 1, StartedAtKnown: 1, EndedAtKnown: 1} + return d +} + +func strptr(value string) *string { return &value } + +func minimumDocument() Document { + return Document{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", Digest: "sha256:" + zeros, ProjectID: "project-p", GenerationID: "generation-1", ProjectViewDigest: "sha256:" + ones, GeneratedAt: "2026-09-04T00:00:00Z", SortVersion: SortVersion, Sessions: []Entry{}, Coverage: IndexCoverage{}} +} + +const zeros = "0000000000000000000000000000000000000000000000000000000000000000" +const ones = "1111111111111111111111111111111111111111111111111111111111111111" diff --git a/internal/strictjson/codec.go b/internal/strictjson/codec.go new file mode 100644 index 0000000..59d7aa2 --- /dev/null +++ b/internal/strictjson/codec.go @@ -0,0 +1,270 @@ +// Package strictjson contains the single bounded JSON boundary used by v4 +// wire contracts. It deliberately does not know any package-specific schema. +package strictjson + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strings" + "unicode/utf8" +) + +const MaxBytes = 64 << 20 + +func Decode(data []byte, dst any) error { + if len(data) > MaxBytes { + return fmt.Errorf("json exceeds %d bytes", MaxBytes) + } + if !utf8.Valid(data) { + return errors.New("json is not valid UTF-8") + } + dec := json.NewDecoder(bytes.NewReader(data)) + dec.UseNumber() + if err := scanValue(dec); err != nil { + return fmt.Errorf("invalid JSON: %w", err) + } + if err := expectEOF(dec); err != nil { + return err + } + var raw any + rawDecoder := json.NewDecoder(bytes.NewReader(data)) + rawDecoder.UseNumber() + if err := rawDecoder.Decode(&raw); err != nil { + return fmt.Errorf("decode JSON shape: %w", err) + } + dec = json.NewDecoder(bytes.NewReader(data)) + dec.DisallowUnknownFields() + dec.UseNumber() + if err := dec.Decode(dst); err != nil { + return fmt.Errorf("decode JSON: %w", err) + } + return validateWireShape(raw, reflect.ValueOf(dst)) +} + +func Encode(v any) ([]byte, error) { + if err := validateUTF8Strings(reflect.ValueOf(v), make(map[visit]bool)); err != nil { + return nil, err + } + b, err := json.Marshal(v) + if err != nil { + return nil, err + } + if len(b) > MaxBytes { + return nil, fmt.Errorf("json exceeds %d bytes", MaxBytes) + } + return b, nil +} + +type visit struct { + kind reflect.Kind + ptr uintptr +} + +func validateUTF8Strings(value reflect.Value, seen map[visit]bool) error { + if !value.IsValid() { + return nil + } + for value.Kind() == reflect.Interface { + if value.IsNil() { + return nil + } + value = value.Elem() + } + switch value.Kind() { + case reflect.String: + if !utf8.ValidString(value.String()) { + return errors.New("JSON value contains invalid UTF-8") + } + case reflect.Pointer: + if value.IsNil() { + return nil + } + key := visit{kind: value.Kind(), ptr: value.Pointer()} + if seen[key] { + return nil + } + seen[key] = true + return validateUTF8Strings(value.Elem(), seen) + case reflect.Struct: + for index := 0; index < value.NumField(); index++ { + if err := validateUTF8Strings(value.Field(index), seen); err != nil { + return err + } + } + case reflect.Map: + if value.IsNil() { + return nil + } + key := visit{kind: value.Kind(), ptr: value.Pointer()} + if seen[key] { + return nil + } + seen[key] = true + iterator := value.MapRange() + for iterator.Next() { + if err := validateUTF8Strings(iterator.Key(), seen); err != nil { + return err + } + if err := validateUTF8Strings(iterator.Value(), seen); err != nil { + return err + } + } + case reflect.Slice: + if value.IsNil() { + return nil + } + key := visit{kind: value.Kind(), ptr: value.Pointer()} + if seen[key] { + return nil + } + seen[key] = true + fallthrough + case reflect.Array: + for index := 0; index < value.Len(); index++ { + if err := validateUTF8Strings(value.Index(index), seen); err != nil { + return err + } + } + } + return nil +} + +func expectEOF(dec *json.Decoder) error { + var extra any + if err := dec.Decode(&extra); err != io.EOF { + if err == nil { + return errors.New("trailing JSON value") + } + return fmt.Errorf("trailing JSON: %w", err) + } + return nil +} + +func scanValue(dec *json.Decoder) error { + t, err := dec.Token() + if err != nil { + return err + } + if d, ok := t.(json.Delim); ok { + switch d { + case '{': + seen := map[string]struct{}{} + for dec.More() { + key, err := dec.Token() + if err != nil { + return err + } + ks, ok := key.(string) + if !ok { + return errors.New("object key is not a string") + } + if _, exists := seen[ks]; exists { + return fmt.Errorf("duplicate object key %q", ks) + } + seen[ks] = struct{}{} + if err := scanValue(dec); err != nil { + return err + } + } + end, err := dec.Token() + if err != nil { + return err + } + if end != json.Delim('}') { + return errors.New("malformed object") + } + case '[': + for dec.More() { + if err := scanValue(dec); err != nil { + return err + } + } + end, err := dec.Token() + if err != nil { + return err + } + if end != json.Delim(']') { + return errors.New("malformed array") + } + default: + return fmt.Errorf("unexpected delimiter %q", d) + } + } + return nil +} + +func validateWireShape(raw any, destination reflect.Value) error { + if destination.Kind() != reflect.Pointer || destination.IsNil() { + return errors.New("decode destination must be a non-nil pointer") + } + return validateShapeValue(raw, destination.Elem(), "$", false) +} + +func validateShapeValue(raw any, destination reflect.Value, path string, nullable bool) error { + if raw == nil { + if nullable { + return nil + } + return fmt.Errorf("%s must not be null", path) + } + for destination.Kind() == reflect.Pointer { + if destination.IsNil() { + return nil + } + destination = destination.Elem() + } + switch destination.Kind() { + case reflect.Struct: + object, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("%s must be an object", path) + } + t := destination.Type() + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + jsonName := strings.Split(field.Tag.Get("json"), ",")[0] + if jsonName == "" { + jsonName = field.Name + } + if jsonName == "-" { + continue + } + value, exists := object[jsonName] + required := field.Tag.Get("required") == "true" + fieldNullable := field.Tag.Get("nullable") == "true" + if !exists { + if required { + return fmt.Errorf("%s.%s is required", path, jsonName) + } + continue + } + if !required && field.Type.Kind() == reflect.Pointer && value == nil { + return fmt.Errorf("%s.%s must be omitted instead of null", path, jsonName) + } + if err := validateShapeValue(value, destination.Field(i), path+"."+jsonName, fieldNullable); err != nil { + return err + } + } + case reflect.Slice, reflect.Array: + array, ok := raw.([]any) + if !ok { + return fmt.Errorf("%s must be an array", path) + } + for i, value := range array { + var element reflect.Value + if i < destination.Len() { + element = destination.Index(i) + } else { + element = reflect.New(destination.Type().Elem()).Elem() + } + if err := validateShapeValue(value, element, fmt.Sprintf("%s[%d]", path, i), false); err != nil { + return err + } + } + } + return nil +} diff --git a/internal/strictjson/codec_test.go b/internal/strictjson/codec_test.go new file mode 100644 index 0000000..17dd924 --- /dev/null +++ b/internal/strictjson/codec_test.go @@ -0,0 +1,84 @@ +package strictjson + +import ( + "bytes" + "strings" + "testing" +) + +func TestDecodeRejectsDuplicateNestedKeys(t *testing.T) { + var v map[string]any + if err := Decode([]byte(`{"a":{"x":1,"x":2}}`), &v); err == nil { + t.Fatal("accepted duplicate key") + } +} + +func TestDecodeRejectsTrailingAndUnknown(t *testing.T) { + var v struct { + A int `json:"a"` + } + if err := Decode([]byte(`{"a":1} {"a":2}`), &v); err == nil { + t.Fatal("accepted trailing value") + } + if err := Decode([]byte(`{"a":1,"b":2}`), &v); err == nil { + t.Fatal("accepted unknown field") + } + if err := Decode([]byte(`{"a":1} garbage`), &v); err == nil { + t.Fatal("accepted trailing garbage") + } +} + +func TestDecodeRejectsInvalidUTF8(t *testing.T) { + var v map[string]any + if err := Decode([]byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, &v); err == nil { + t.Fatal("accepted invalid utf8") + } +} + +func TestEncodeDeterministic(t *testing.T) { + a, err := Encode(struct { + Z int `json:"z"` + A string `json:"a"` + }{Z: 1, A: "x"}) + if err != nil || !strings.EqualFold(string(a), `{"z":1,"a":"x"}`) { + t.Fatalf("%s %v", a, err) + } +} + +func TestEncodeRejectsInvalidUTF8(t *testing.T) { + if _, err := Encode(struct { + Value string `json:"value"` + }{Value: string([]byte{0xff})}); err == nil { + t.Fatal("encoded invalid UTF-8 by silently replacing it") + } +} + +func TestDecodeRejectsPayloadAbove64MiB(t *testing.T) { + var v any + if err := Decode(bytes.Repeat([]byte{' '}, MaxBytes+1), &v); err == nil { + t.Fatal("accepted oversized payload") + } +} + +func TestDecodeEnforcesRequiredAndNullableWireShape(t *testing.T) { + type wire struct { + Required string `json:"required" required:"true"` + Nullable *string `json:"nullable" required:"true" nullable:"true"` + Optional *string `json:"optional,omitempty"` + } + for _, body := range []string{ + `{"nullable":null}`, + `{"required":"ok"}`, + `{"required":"ok","nullable":null,"optional":null}`, + `{"required":null,"nullable":null}`, + } { + var got wire + if err := Decode([]byte(body), &got); err == nil { + t.Fatalf("accepted invalid wire shape %s", body) + } + } + var got wire + if err := Decode([]byte(`{"required":"ok","nullable":null}`), &got); err != nil { + t.Fatalf("rejected required nullable field: %v", err) + } +} From 752c93f159c44fc14d371213eb1f297bdebb144e Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 14:40:53 +0800 Subject: [PATCH 05/25] fix: tighten v4 wire validator invariants --- .../task-2-report.md | 15 ++ internal/inspect/validate.go | 9 +- internal/inspect/validate_test.go | 12 +- internal/pricing/validate.go | 11 +- internal/pricing/validate_test.go | 11 + internal/reviewv4/codec.go | 16 -- internal/reviewv4/codec_test.go | 246 ++++++++++++++---- internal/reviewv4/types.go | 26 +- internal/reviewv4/validate.go | 43 +-- internal/sessionindex/validate.go | 18 +- internal/sessionindex/validate_test.go | 10 + internal/strictjson/codec.go | 113 ++++++-- internal/strictjson/codec_test.go | 34 +++ 13 files changed, 439 insertions(+), 125 deletions(-) diff --git a/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md index a6a9b4b..477e244 100644 --- a/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md +++ b/.superpowers/sdd/2026-09-04-obsidian-context-gate-0-contracts/task-2-report.md @@ -37,3 +37,18 @@ The ensuing implementation replaced those paths, after which the focused six-pac ## Concerns No unresolved Task 2 blocker. Runtime validation deliberately adds semantic invariants that JSON Schema cannot express (coverage arithmetic, graph consistency, canonical digest verification, cross-file bindings, and price reconciliation) without changing the frozen wire shape. + +## Fix Round 1 + +The review found two Critical, four Important, and one Minor gap in the initial replacement. Regression tests were added before implementation. The focused RED output is retained in `evidence/task-2-fix1-red.txt`; it demonstrated acceptance of case-folded aliases, loss of explicit empty optional arrays, wrapped coverage arithmetic, contamination from historical incomplete pricing, a known aggregate beside an unknown model cost, and form-feed URL whitespace. + +The strict decoder now constructs exact allowed-key sets recursively for structs, embedded fields, pointers, slices, and typed map values while preserving arbitrary keys only at declared map boundaries. Patch and baseline optional arrays use presence-bearing pointers, so omitted and explicit-empty values remain distinct in canonical ledger input. Coverage equations use checked addition. Ledger aggregate completeness is derived only from validated current snapshot IDs, and any included model with unknown cost requires a null aggregate. Pricing provenance rejects all Unicode whitespace and control characters. Cross-file mutation tests now require every mutated artifact to render and validate independently, recompute dependent hashes, and fail only at the intended projection binding. + +Fresh verification after the fixes: + +- Focused six-package command: PASS. +- Frozen Task 1 fixture boundary: PASS. +- `go test -p 1 -timeout 5m ./...`: PASS through `test/zerotoken` in approximately 50 seconds. +- `go vet ./...`, `go mod tidy -diff`, and `git diff --check`: PASS. + +No frozen schemas, fixtures, documentation, or plans were changed. No unresolved Fix Round 1 concern remains. diff --git a/internal/inspect/validate.go b/internal/inspect/validate.go index 64ea6bb..292baed 100644 --- a/internal/inspect/validate.go +++ b/internal/inspect/validate.go @@ -21,7 +21,14 @@ var eventKinds = map[string]bool{ func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } func validCoverage(coverage Coverage) bool { - return coverage.Indexed+coverage.Collapsed+coverage.Unprojected+coverage.Undecodable+coverage.Truncated == coverage.Seen + total := uint64(0) + for _, value := range []uint64{coverage.Indexed, coverage.Collapsed, coverage.Unprojected, coverage.Undecodable, coverage.Truncated} { + if ^uint64(0)-total < value { + return false + } + total += value + } + return total == coverage.Seen } func validateIdentity(schemaVersion int, reader, project, provider, session, generation, digest string) error { diff --git a/internal/inspect/validate_test.go b/internal/inspect/validate_test.go index d3763b2..f9b1e79 100644 --- a/internal/inspect/validate_test.go +++ b/internal/inspect/validate_test.go @@ -1,9 +1,11 @@ package inspect import ( - "github.com/neomei/SessionReviewer/internal/strictjson" + "math" "os" "testing" + + "github.com/neomei/SessionReviewer/internal/strictjson" ) func TestRenderFrozenValidSummaryFixture(t *testing.T) { @@ -20,6 +22,14 @@ func TestRenderFrozenValidSummaryFixture(t *testing.T) { } } +func TestValidateRejectsCoverageAdditionOverflow(t *testing.T) { + summary := minimumSummary() + summary.Coverage = Coverage{Seen: 0, Indexed: math.MaxUint64, Collapsed: 1} + if err := ValidateSummary(summary); err == nil { + t.Fatal("accepted wrapped summary coverage") + } +} + func TestParsersRejectFrozenInvalidFixtures(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/pricing/validate.go b/internal/pricing/validate.go index 6f831fc..18f0b26 100644 --- a/internal/pricing/validate.go +++ b/internal/pricing/validate.go @@ -7,6 +7,7 @@ import ( "reflect" "regexp" "strings" + "unicode" "github.com/neomei/SessionReviewer/internal/strictjson" ) @@ -26,7 +27,15 @@ func bounded(value string, maximum int, nonempty bool) bool { return len(value) <= maximum && (!nonempty || value != "") } func validURL(value string) bool { - return bounded(value, maxURL, true) && len(value) > len("https://") && strings.HasPrefix(value, "https://") && !strings.ContainsAny(value, " \t\r\n") + if !bounded(value, maxURL, true) || len(value) <= len("https://") || !strings.HasPrefix(value, "https://") { + return false + } + for _, character := range value { + if unicode.IsSpace(character) || unicode.IsControl(character) { + return false + } + } + return true } func validOptional(value *string, maximum int) bool { return value == nil || len(*value) <= maximum } func validOptionalURL(value *string) bool { return value == nil || validURL(*value) } diff --git a/internal/pricing/validate_test.go b/internal/pricing/validate_test.go index 569ad6c..ab54a2c 100644 --- a/internal/pricing/validate_test.go +++ b/internal/pricing/validate_test.go @@ -117,6 +117,17 @@ func TestPricingSupplementFixtureParityAndNullMeansUnknown(t *testing.T) { } } +func TestValidatePricingURLRejectsAllJSONSchemaWhitespace(t *testing.T) { + for _, whitespace := range []string{"\f", "\v"} { + snapshot := completeSnapshot() + url := "https://example.test/" + whitespace + "path" + snapshot.SourceURL = &url + if err := ValidateSnapshot(snapshot); err == nil { + t.Fatalf("accepted URL containing %q", whitespace) + } + } +} + func completeSnapshot() Snapshot { v := 1.0 five := 5.0 diff --git a/internal/reviewv4/codec.go b/internal/reviewv4/codec.go index c31b57e..cb64ee8 100644 --- a/internal/reviewv4/codec.go +++ b/internal/reviewv4/codec.go @@ -157,16 +157,9 @@ func normalizeLedger(ledger *MachineLedger) { if ledger.OrphanPatches == nil { ledger.OrphanPatches = []Patch{} } - normalizePatches(ledger.HumanPatches) - normalizePatches(ledger.OrphanPatches) if ledger.GeneratedBaselines == nil { ledger.GeneratedBaselines = []Baseline{} } - for index := range ledger.GeneratedBaselines { - if len(ledger.GeneratedBaselines[index].Values) == 0 { - ledger.GeneratedBaselines[index].Values = nil - } - } if ledger.PricingSnapshots == nil { ledger.PricingSnapshots = []pricing.Snapshot{} } @@ -179,14 +172,5 @@ func normalizeLedger(ledger *MachineLedger) { ledger.CurrentPricingSnapshotIDs = []string{} } } - -func normalizePatches(patches []Patch) { - for index := range patches { - if len(patches[index].Values) == 0 { - patches[index].Values = nil - } - } -} - func sha256Hex(data []byte) string { sum := sha256.Sum256(data); return hex.EncodeToString(sum[:]) } func isZeroSHA(value string) bool { return value == strings.Repeat("0", 64) } diff --git a/internal/reviewv4/codec_test.go b/internal/reviewv4/codec_test.go index 83d213f..dec3300 100644 --- a/internal/reviewv4/codec_test.go +++ b/internal/reviewv4/codec_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/neomei/SessionReviewer/internal/pricing" "github.com/neomei/SessionReviewer/internal/sessionindex" ) @@ -73,35 +74,116 @@ func TestValidatePresentationRejectsDecisionCycleAndBrokenGraph(t *testing.T) { func TestLoadProjectionEnforcesAllIdentityAndDigestBindings(t *testing.T) { reviewFixture := mustRead(t, "../../testdata/contracts/v4/review-presentation-v4.valid.json") indexFixture := mustRead(t, "../../testdata/contracts/v4/session-index-v1.valid.json") - review, err := DecodePresentation(reviewFixture) + ledgerFixture := mustRead(t, "../../testdata/contracts/v4/machine-ledger-v4.valid.json") + tests := []struct { + name string + mutateDocs func(*Presentation, *MachineLedger, *sessionindex.Document, *[]byte) + breakBind func(*MachineLedger) + }{ + {name: "project ID", mutateDocs: func(_ *Presentation, ledger *MachineLedger, _ *sessionindex.Document, _ *[]byte) { + ledger.ProjectID = "other" + for index := range ledger.PricingSnapshots { + ledger.PricingSnapshots[index].ProjectID = "other" + } + }}, + {name: "generation ID", mutateDocs: func(_ *Presentation, _ *MachineLedger, index *sessionindex.Document, _ *[]byte) { + index.GenerationID = "other" + }}, + {name: "project view digest", mutateDocs: func(p *Presentation, _ *MachineLedger, _ *sessionindex.Document, _ *[]byte) { + p.ProjectViewDigest = "sha256:" + strings.Repeat("9", 64) + }}, + {name: "review digest", breakBind: func(ledger *MachineLedger) { + ledger.ReviewSHA256 = strings.Repeat("9", 64) + ledger.SyncHashes.ReviewSHA256 = ledger.ReviewSHA256 + }}, + {name: "history digest", breakBind: func(ledger *MachineLedger) { + ledger.HistorySHA256 = strings.Repeat("9", 64) + ledger.SyncHashes.HistorySHA256 = ledger.HistorySHA256 + }}, + {name: "index digest", breakBind: func(ledger *MachineLedger) { + ledger.SyncHashes.SessionIndexDigest = "sha256:" + strings.Repeat("9", 64) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + presentation, err := DecodePresentation(reviewFixture) + if err != nil { + t.Fatal(err) + } + ledger, err := DecodeLedger(ledgerFixture) + if err != nil { + t.Fatal(err) + } + index, err := sessionindex.Parse(indexFixture) + if err != nil { + t.Fatal(err) + } + history := []byte("# project history\n") + if tc.mutateDocs != nil { + tc.mutateDocs(&presentation, &ledger, &index, &history) + } + reviewBytes, err := json.Marshal(presentation) + if err != nil { + t.Fatal(err) + } + if _, err := DecodePresentation(reviewBytes); err != nil { + t.Fatalf("mutated review is independently invalid: %v", err) + } + indexBytes, err := sessionindex.Render(index) + if err != nil { + t.Fatalf("mutated index is independently invalid: %v", err) + } + validatedIndex, err := sessionindex.Parse(indexBytes) + if err != nil { + t.Fatal(err) + } + ledger.AcceptedRevision = presentation.Revision + ledger.ReviewSHA256 = sha256hex(reviewBytes) + ledger.HistorySHA256 = sha256hex(history) + ledger.SyncHashes.ReviewSHA256 = ledger.ReviewSHA256 + ledger.SyncHashes.HistorySHA256 = ledger.HistorySHA256 + ledger.SyncHashes.SessionIndexDigest = validatedIndex.Digest + if tc.breakBind != nil { + tc.breakBind(&ledger) + } + ledgerBytes, err := RenderLedger(ledger) + if err != nil { + t.Fatalf("mutated ledger is independently invalid: %v", err) + } + if _, err := DecodeLedger(ledgerBytes); err != nil { + t.Fatalf("rendered ledger is independently invalid: %v", err) + } + if _, err := LoadProjection(reviewBytes, history, ledgerBytes, indexBytes); err == nil { + t.Fatal("accepted mismatched projection") + } + }) + } + + presentation, err := DecodePresentation(reviewFixture) if err != nil { t.Fatal(err) } - index, err := sessionindex.Parse(indexFixture) + ledger, err := DecodeLedger(ledgerFixture) if err != nil { t.Fatal(err) } - indexBytes, err := sessionindex.Render(index) + index, err := sessionindex.Parse(indexFixture) if err != nil { t.Fatal(err) } - index, err = sessionindex.Parse(indexBytes) + indexBytes, err := sessionindex.Render(index) if err != nil { t.Fatal(err) } - ledgerFixture := mustRead(t, "../../testdata/contracts/v4/machine-ledger-v4.valid.json") - ledger, err := DecodeLedger(ledgerFixture) + index, err = sessionindex.Parse(indexBytes) if err != nil { t.Fatal(err) } - ledger.AcceptedRevision = review.Revision - history := reviewFixture - ledger.ReviewSHA256 = sha256hex(reviewFixture) - ledger.HistorySHA256 = sha256hex(history) - ledger.SyncHashes.ReviewSHA256 = ledger.ReviewSHA256 - ledger.SyncHashes.HistorySHA256 = ledger.HistorySHA256 + history := []byte("# project history\n") + ledger.AcceptedRevision = presentation.Revision + ledger.ReviewSHA256, ledger.HistorySHA256 = sha256hex(reviewFixture), sha256hex(history) + ledger.SyncHashes.ReviewSHA256, ledger.SyncHashes.HistorySHA256 = ledger.ReviewSHA256, ledger.HistorySHA256 ledger.SyncHashes.SessionIndexDigest = index.Digest - ledger.SyncHashes.LedgerSHA256 = strings.Repeat("0", 64) ledgerBytes, err := RenderLedger(ledger) if err != nil { t.Fatal(err) @@ -110,53 +192,12 @@ func TestLoadProjectionEnforcesAllIdentityAndDigestBindings(t *testing.T) { if err != nil { t.Fatal(err) } - if accepted.SessionIndex.ProjectID != review.ProjectID { - t.Fatalf("validated index missing from Accepted: %+v", accepted.SessionIndex) + if accepted.SessionIndex.ProjectID != presentation.ProjectID { + t.Fatal("validated index missing from Accepted") } if _, err := LoadProjection(reviewFixture, history, ledgerBytes, nil); err == nil { t.Fatal("accepted projection without required session index") } - - tests := []struct { - name string - mutate func(*Presentation, *MachineLedger, *sessionindex.Document, *[]byte) - }{ - {name: "project ID", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { l.ProjectID = "other" }}, - {name: "generation ID", mutate: func(_ *Presentation, _ *MachineLedger, i *sessionindex.Document, _ *[]byte) { i.GenerationID = "other" }}, - {name: "project view digest", mutate: func(p *Presentation, _ *MachineLedger, _ *sessionindex.Document, _ *[]byte) { - p.ProjectViewDigest = "sha256:" + strings.Repeat("9", 64) - }}, - {name: "review digest", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { - l.ReviewSHA256 = strings.Repeat("9", 64) - l.SyncHashes.ReviewSHA256 = l.ReviewSHA256 - }}, - {name: "history digest", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, h *[]byte) { - *h = append(*h, ' ') - l.HistorySHA256 = strings.Repeat("9", 64) - l.SyncHashes.HistorySHA256 = l.HistorySHA256 - }}, - {name: "index digest", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { - l.SyncHashes.SessionIndexDigest = "sha256:" + strings.Repeat("9", 64) - }}, - {name: "ledger top sync disagreement", mutate: func(_ *Presentation, l *MachineLedger, _ *sessionindex.Document, _ *[]byte) { - l.SyncHashes.ReviewSHA256 = strings.Repeat("8", 64) - }}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - p := review - l := ledger - i := index - h := append([]byte(nil), history...) - tc.mutate(&p, &l, &i, &h) - rb, _ := jsonBytes(p) - ib, _ := sessionindex.Render(i) - lb, _ := RenderLedger(l) - if _, err := LoadProjection(rb, h, lb, ib); err == nil { - t.Fatal("accepted mismatched projection") - } - }) - } } func TestParseAcceptsRawMarkdownHistoryAndRejectsInvalidUTF8(t *testing.T) { @@ -210,6 +251,99 @@ func TestDecodeLedgerRejectsTamperedSelfDigest(t *testing.T) { } } +func TestRenderLedgerPreservesExplicitEmptyOptionalArrays(t *testing.T) { + fixture := mustRead(t, "../../testdata/contracts/v4/machine-ledger-v4.valid.json") + var raw map[string]any + if err := json.Unmarshal(fixture, &raw); err != nil { + t.Fatal(err) + } + raw["human_patches"] = []any{map[string]any{ + "entity_id": "entity-1", "field": "field-1", "operation": "set", + "values": []any{}, "base_generated_hash": strings.Repeat("1", 64), + }} + raw["generated_baselines"] = []any{map[string]any{ + "generation_id": "generation-1", "entity_id": "entity-1", "field": "field-1", "kind": "list", + "values": []any{}, "generated_hash": strings.Repeat("2", 64), + }} + body, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + ledger, err := DecodeLedger(body) + if err != nil { + t.Fatal(err) + } + rendered, err := RenderLedger(ledger) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(rendered, &got); err != nil { + t.Fatal(err) + } + patch := got["human_patches"].([]any)[0].(map[string]any) + baseline := got["generated_baselines"].([]any)[0].(map[string]any) + for name, value := range map[string]any{"patch": patch["values"], "baseline": baseline["values"]} { + items, present := value.([]any) + if !present || len(items) != 0 { + t.Fatalf("%s explicit empty values were not preserved: %#v", name, value) + } + } +} + +func TestValidateLedgerUsesOnlyCurrentPricingForAggregateCompleteness(t *testing.T) { + ledger := frozenLedger(t) + historical := ledger.PricingSnapshots[0] + current := completePricingSnapshot(t, "snapshot-current") + zero := 0.0 + ledger.PricingSnapshots = []pricing.Snapshot{historical, current} + ledger.CurrentPricingSnapshotIDs = []string{current.SnapshotID} + ledger.Accounting.TotalCostUSD = &zero + if err := ValidateLedger(ledger); err != nil { + t.Fatalf("incomplete historical predecessor contaminated current aggregate: %v", err) + } +} + +func TestValidateLedgerRequiresNullAggregateWhenModelCostUnknown(t *testing.T) { + ledger := frozenLedger(t) + one := 1.0 + ledger.PricingSnapshots = []pricing.Snapshot{} + ledger.CurrentPricingSnapshotIDs = []string{} + ledger.Accounting.TotalTokens = 1 + ledger.Accounting.TotalCostUSD = &one + ledger.Accounting.Models = []Model{{Model: "model-1", TotalTokens: 1, TotalCostUSD: nil}} + if err := ValidateLedger(ledger); err == nil { + t.Fatal("accepted non-null aggregate with unknown included model cost") + } +} + +func frozenLedger(t *testing.T) MachineLedger { + t.Helper() + ledger, err := DecodeLedger(mustRead(t, "../../testdata/contracts/v4/machine-ledger-v4.valid.json")) + if err != nil { + t.Fatal(err) + } + return ledger +} + +func completePricingSnapshot(t *testing.T, id string) pricing.Snapshot { + t.Helper() + snapshot, err := pricing.Parse(mustRead(t, "../../testdata/contracts/v4/pricing-snapshot-v1.valid.json")) + if err != nil { + t.Fatal(err) + } + zero := 0.0 + snapshot.SnapshotID = id + snapshot.Rates = pricing.Rates{Input: &zero, CachedInput: &zero, CacheWriteInput: &zero, Output: &zero, ReasoningOutput: &zero} + snapshot.LineCostsUSD = pricing.LineCosts{Input: &zero, CachedInput: &zero, CacheWriteInput: &zero, Output: &zero, ReasoningOutput: &zero} + snapshot.MissingBillingDimensions = []string{} + snapshot.KnownSubtotalUSD, snapshot.TotalCostUSD, snapshot.PricingComplete = 0, &zero, true + if err := pricing.ValidateSnapshot(snapshot); err != nil { + t.Fatal(err) + } + return snapshot +} + func minimumPresentation() Presentation { return Presentation{SchemaVersion: 4, MinimumReaderVersion: "0.4.0", MinimumWriterVersion: "0.4.0", ProjectID: "p", GenerationID: "g", ProjectViewDigest: "sha256:" + strings.Repeat("1", 64), CurrentState: CurrentState{}, Timeline: []Timeline{}, Decisions: []Decision{}, Risks: []Risk{}, OpenLoops: []OpenLoop{}, HumanPatches: []Patch{}, OrphanPatches: []Patch{}, GeneratedBaselines: []Baseline{}} } diff --git a/internal/reviewv4/types.go b/internal/reviewv4/types.go index 0c4e829..cac378b 100644 --- a/internal/reviewv4/types.go +++ b/internal/reviewv4/types.go @@ -97,21 +97,21 @@ type OpenLoop struct { CompletionCriterion string `json:"completion_criterion" required:"true"` } type Patch struct { - EntityID string `json:"entity_id" required:"true"` - Field string `json:"field" required:"true"` - Operation string `json:"operation" required:"true"` - Value *string `json:"value,omitempty"` - Values []string `json:"values,omitempty"` - BaseGeneratedHash string `json:"base_generated_hash" required:"true"` + EntityID string `json:"entity_id" required:"true"` + Field string `json:"field" required:"true"` + Operation string `json:"operation" required:"true"` + Value *string `json:"value,omitempty"` + Values *[]string `json:"values,omitempty"` + BaseGeneratedHash string `json:"base_generated_hash" required:"true"` } type Baseline struct { - GenerationID string `json:"generation_id" required:"true"` - EntityID string `json:"entity_id" required:"true"` - Field string `json:"field" required:"true"` - Kind string `json:"kind" required:"true"` - Value *string `json:"value,omitempty"` - Values []string `json:"values,omitempty"` - GeneratedHash string `json:"generated_hash" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + EntityID string `json:"entity_id" required:"true"` + Field string `json:"field" required:"true"` + Kind string `json:"kind" required:"true"` + Value *string `json:"value,omitempty"` + Values *[]string `json:"values,omitempty"` + GeneratedHash string `json:"generated_hash" required:"true"` } type Presentation struct { SchemaVersion int `json:"schema_version" required:"true"` diff --git a/internal/reviewv4/validate.go b/internal/reviewv4/validate.go index 7fc03b8..8b775c0 100644 --- a/internal/reviewv4/validate.go +++ b/internal/reviewv4/validate.go @@ -17,6 +17,20 @@ func validID(value string) bool { return len(value) <= 256 && func text(value string, maximum int) bool { return len(value) <= maximum } func optionalText(value *string, maximum int) bool { return value == nil || text(*value, maximum) } func optionalDigest(value *string) bool { return value == nil || digestRE.MatchString(*value) } +func optionalTexts(values *[]string, maximumItems, maximumText int) bool { + if values == nil { + return true + } + if *values == nil || len(*values) > maximumItems { + return false + } + for _, value := range *values { + if !text(value, maximumText) { + return false + } + } + return true +} func ValidatePresentation(p Presentation) error { if p.SchemaVersion != 4 || p.MinimumReaderVersion != "0.4.0" || p.MinimumWriterVersion != "0.4.0" || !validID(p.ProjectID) || !validID(p.GenerationID) || !digestRE.MatchString(p.ProjectViewDigest) || p.Revision < 0 { @@ -139,14 +153,9 @@ func ValidatePresentation(p Presentation) error { } } for _, baseline := range p.GeneratedBaselines { - if !validID(baseline.GenerationID) || !validID(baseline.EntityID) || !validID(baseline.Field) || !validID(baseline.Kind) || !shaRE.MatchString(baseline.GeneratedHash) || !optionalText(baseline.Value, 16384) || len(baseline.Values) > 256 { + if !validID(baseline.GenerationID) || !validID(baseline.EntityID) || !validID(baseline.Field) || !validID(baseline.Kind) || !shaRE.MatchString(baseline.GeneratedHash) || !optionalText(baseline.Value, 16384) || !optionalTexts(baseline.Values, 256, 16384) { return errors.New("invalid generated baseline") } - for _, value := range baseline.Values { - if !text(value, 16384) { - return errors.New("baseline value exceeds limit") - } - } } return nil } @@ -163,7 +172,7 @@ func uniqueIDs(values []string) error { } func validatePatch(patch Patch) error { - if !validID(patch.EntityID) || !validID(patch.Field) || !shaRE.MatchString(patch.BaseGeneratedHash) || !optionalText(patch.Value, 16384) || len(patch.Values) > 256 { + if !validID(patch.EntityID) || !validID(patch.Field) || !shaRE.MatchString(patch.BaseGeneratedHash) || !optionalText(patch.Value, 16384) || !optionalTexts(patch.Values, 256, 16384) { return errors.New("invalid presentation patch") } switch patch.Operation { @@ -171,11 +180,6 @@ func validatePatch(patch Patch) error { default: return errors.New("invalid patch operation") } - for _, value := range patch.Values { - if !text(value, 16384) { - return errors.New("patch value exceeds limit") - } - } return nil } @@ -216,7 +220,6 @@ func ValidateLedger(l MachineLedger) error { if !money(l.Accounting.TotalCostUSD) { return errors.New("invalid aggregate cost") } - incompletePricing := false pricingByID := map[string]pricing.Snapshot{} for _, snapshot := range l.PricingSnapshots { if err := pricing.ValidateSnapshot(snapshot); err != nil { @@ -229,10 +232,6 @@ func ValidateLedger(l MachineLedger) error { return errors.New("duplicate pricing snapshot") } pricingByID[snapshot.SnapshotID] = snapshot - incompletePricing = incompletePricing || !snapshot.PricingComplete - } - if incompletePricing && l.Accounting.TotalCostUSD != nil { - return errors.New("aggregate price must be null when a snapshot is incomplete") } modelNames := map[string]bool{} var modelTokens uint64 @@ -256,6 +255,9 @@ func ValidateLedger(l MachineLedger) error { if len(l.Accounting.Models) > 0 && modelTokens != l.Accounting.TotalTokens { return errors.New("accounting token total does not reconcile") } + if len(l.Accounting.Models) > 0 && !modelCostsComplete && l.Accounting.TotalCostUSD != nil { + return errors.New("aggregate price must be null when an included model cost is unknown") + } if len(l.Accounting.Models) > 0 && modelCostsComplete && l.Accounting.TotalCostUSD != nil && !nearlyEqual(*l.Accounting.TotalCostUSD, modelCost) { return errors.New("accounting cost total does not reconcile") } @@ -283,17 +285,22 @@ func ValidateLedger(l MachineLedger) error { } } for _, baseline := range l.GeneratedBaselines { - if !validID(baseline.GenerationID) || !validID(baseline.EntityID) || !validID(baseline.Field) || !validID(baseline.Kind) || !shaRE.MatchString(baseline.GeneratedHash) || !optionalText(baseline.Value, 16384) || len(baseline.Values) > 256 { + if !validID(baseline.GenerationID) || !validID(baseline.EntityID) || !validID(baseline.Field) || !validID(baseline.Kind) || !shaRE.MatchString(baseline.GeneratedHash) || !optionalText(baseline.Value, 16384) || !optionalTexts(baseline.Values, 256, 16384) { return errors.New("invalid ledger baseline") } } seenCurrent := map[string]bool{} + currentPricingIncomplete := false for _, id := range l.CurrentPricingSnapshotIDs { snapshot, exists := pricingByID[id] if !validID(id) || !exists || seenCurrent[id] || snapshot.Status == pricing.PriceSuperseded { return errors.New("invalid current pricing snapshot reference") } seenCurrent[id] = true + currentPricingIncomplete = currentPricingIncomplete || !snapshot.PricingComplete + } + if currentPricingIncomplete && l.Accounting.TotalCostUSD != nil { + return errors.New("aggregate price must be null when a current snapshot is incomplete") } if !shaRE.MatchString(l.SyncHashes.ReviewSHA256) || !shaRE.MatchString(l.SyncHashes.HistorySHA256) || !shaRE.MatchString(l.SyncHashes.LedgerSHA256) || !digestRE.MatchString(l.SyncHashes.SessionIndexDigest) { return errors.New("invalid synchronization hashes") diff --git a/internal/sessionindex/validate.go b/internal/sessionindex/validate.go index c29d79d..9dbfe88 100644 --- a/internal/sessionindex/validate.go +++ b/internal/sessionindex/validate.go @@ -28,7 +28,19 @@ func validID(value string) bool { return len(value) <= 256 & func validOptional(value *string, maximum int) bool { return value == nil || len(*value) <= maximum } func validDigest(value *string) bool { return value == nil || digestRE.MatchString(*value) } func reconcileCoverage(coverage Coverage) bool { - return coverage.Indexed+coverage.Collapsed+coverage.Unprojected+coverage.Undecodable+coverage.Truncated == coverage.Seen + total, ok := checkedSum(coverage.Indexed, coverage.Collapsed, coverage.Unprojected, coverage.Undecodable, coverage.Truncated) + return ok && total == coverage.Seen +} + +func checkedSum(values ...uint64) (uint64, bool) { + var total uint64 + for _, value := range values { + if ^uint64(0)-total < value { + return 0, false + } + total += value + } + return total, true } func Validate(document Document) error { @@ -95,7 +107,9 @@ func Validate(document Document) error { return fmt.Errorf("session %s has an invalid digest or generation reference", entry.SessionID) } } - if calculated != document.Coverage || document.Coverage.Complete+document.Coverage.Partial+document.Coverage.Error+document.Coverage.Unprocessed != document.Coverage.Total || document.Coverage.SourceAvailable+document.Coverage.SourceUnavailable != document.Coverage.Total { + states, statesOK := checkedSum(document.Coverage.Complete, document.Coverage.Partial, document.Coverage.Error, document.Coverage.Unprocessed) + sources, sourcesOK := checkedSum(document.Coverage.SourceAvailable, document.Coverage.SourceUnavailable) + if calculated != document.Coverage || !statesOK || states != document.Coverage.Total || !sourcesOK || sources != document.Coverage.Total { return errors.New("index coverage does not reconcile") } if !sort.SliceIsSorted(document.Sessions, func(i, j int) bool { diff --git a/internal/sessionindex/validate_test.go b/internal/sessionindex/validate_test.go index 344184f..970901c 100644 --- a/internal/sessionindex/validate_test.go +++ b/internal/sessionindex/validate_test.go @@ -1,6 +1,7 @@ package sessionindex import ( + "math" "os" "testing" ) @@ -15,6 +16,15 @@ func TestParseFrozenValidFixture(t *testing.T) { } } +func TestValidateRejectsCoverageAdditionOverflow(t *testing.T) { + document := oneSessionDocument() + document.Sessions[0].Coverage = Coverage{Seen: 0, Indexed: math.MaxUint64, Collapsed: 1} + document.Sessions[0].IndexedEventCount = math.MaxUint64 + if err := Validate(document); err == nil { + t.Fatal("accepted wrapped session coverage") + } +} + func TestValidateIdentityUsesProviderAndSessionIDPair(t *testing.T) { d := minimumDocument() d.Sessions = []Entry{{Provider: "claude", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: "now", EndedAt: "now"}, {Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: "now", EndedAt: "now"}} diff --git a/internal/strictjson/codec.go b/internal/strictjson/codec.go index 59d7aa2..a56d689 100644 --- a/internal/strictjson/codec.go +++ b/internal/strictjson/codec.go @@ -223,29 +223,28 @@ func validateShapeValue(raw any, destination reflect.Value, path string, nullabl if !ok { return fmt.Errorf("%s must be an object", path) } - t := destination.Type() - for i := 0; i < t.NumField(); i++ { - field := t.Field(i) - jsonName := strings.Split(field.Tag.Get("json"), ",")[0] - if jsonName == "" { - jsonName = field.Name - } - if jsonName == "-" { - continue + fields := collectJSONFields(destination.Type(), nil) + byName := make(map[string]jsonField, len(fields)) + for _, field := range fields { + byName[field.name] = field + } + for name := range object { + if _, allowed := byName[name]; !allowed { + return fmt.Errorf("%s.%s is not an exact JSON field", path, name) } - value, exists := object[jsonName] - required := field.Tag.Get("required") == "true" - fieldNullable := field.Tag.Get("nullable") == "true" + } + for _, field := range fields { + value, exists := object[field.name] if !exists { - if required { - return fmt.Errorf("%s.%s is required", path, jsonName) + if field.required { + return fmt.Errorf("%s.%s is required", path, field.name) } continue } - if !required && field.Type.Kind() == reflect.Pointer && value == nil { - return fmt.Errorf("%s.%s must be omitted instead of null", path, jsonName) + if !field.required && field.typ.Kind() == reflect.Pointer && value == nil { + return fmt.Errorf("%s.%s must be omitted instead of null", path, field.name) } - if err := validateShapeValue(value, destination.Field(i), path+"."+jsonName, fieldNullable); err != nil { + if err := validateShapeValue(value, fieldByIndex(destination, field.index), path+"."+field.name, field.nullable); err != nil { return err } } @@ -265,6 +264,86 @@ func validateShapeValue(raw any, destination reflect.Value, path string, nullabl return err } } + case reflect.Map: + object, ok := raw.(map[string]any) + if !ok { + return fmt.Errorf("%s must be an object", path) + } + if destination.Type().Key().Kind() != reflect.String { + return fmt.Errorf("%s must use string map keys", path) + } + for key, value := range object { + element := reflect.New(destination.Type().Elem()).Elem() + if current := destination.MapIndex(reflect.ValueOf(key).Convert(destination.Type().Key())); current.IsValid() { + element = current + } + if err := validateShapeValue(value, element, path+"."+key, false); err != nil { + return err + } + } } return nil } + +type jsonField struct { + name string + index []int + typ reflect.Type + required, nullable bool +} + +func collectJSONFields(t reflect.Type, prefix []int) []jsonField { + fields := make([]jsonField, 0, t.NumField()) + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" && !field.Anonymous { + continue + } + tag := field.Tag.Get("json") + name := strings.Split(tag, ",")[0] + if name == "-" { + continue + } + index := append(append([]int(nil), prefix...), i) + embeddedType := field.Type + if embeddedType.Kind() == reflect.Pointer { + embeddedType = embeddedType.Elem() + } + if field.Anonymous && name == "" && embeddedType.Kind() == reflect.Struct { + fields = append(fields, collectJSONFields(embeddedType, index)...) + continue + } + if name == "" { + name = field.Name + } + fields = append(fields, jsonField{ + name: name, index: index, typ: field.Type, + required: field.Tag.Get("required") == "true", + nullable: field.Tag.Get("nullable") == "true", + }) + } + return fields +} + +func fieldByIndex(value reflect.Value, index []int) reflect.Value { + for offset, fieldIndex := range index { + for value.Kind() == reflect.Pointer { + if value.IsNil() { + return reflect.New(fieldTypeAt(value.Type(), index[offset:])).Elem() + } + value = value.Elem() + } + value = value.Field(fieldIndex) + } + return value +} + +func fieldTypeAt(t reflect.Type, index []int) reflect.Type { + for _, fieldIndex := range index { + for t.Kind() == reflect.Pointer { + t = t.Elem() + } + t = t.Field(fieldIndex).Type + } + return t +} diff --git a/internal/strictjson/codec_test.go b/internal/strictjson/codec_test.go index 17dd924..464cffe 100644 --- a/internal/strictjson/codec_test.go +++ b/internal/strictjson/codec_test.go @@ -82,3 +82,37 @@ func TestDecodeEnforcesRequiredAndNullableWireShape(t *testing.T) { t.Fatalf("rejected required nullable field: %v", err) } } + +func TestDecodeRejectsCaseFoldedAliasesAtEveryStructBoundary(t *testing.T) { + type EmbeddedFields struct { + Exact string `json:"exact" required:"true"` + } + type Child struct { + ProjectID string `json:"project_id" required:"true"` + } + type Wire struct { + EmbeddedFields + Child Child `json:"child" required:"true"` + Children []Child `json:"children" required:"true"` + Pointer *Child `json:"pointer" required:"true"` + ChildrenByName map[string]Child `json:"children_by_name" required:"true"` + Labels map[string]string `json:"labels" required:"true"` + } + valid := `{"exact":"ok","child":{"project_id":"child"},"children":[{"project_id":"slice"}],"pointer":{"project_id":"pointer"},"children_by_name":{"map-key":{"project_id":"map-value"}},"labels":{"Arbitrary-Key":"value"}}` + var got Wire + if err := Decode([]byte(valid), &got); err != nil { + t.Fatalf("valid embedded fields or explicit map rejected: %v", err) + } + for _, body := range []string{ + `{"exact":"ok","EXACT":"overwrite","child":{"project_id":"child"},"children":[{"project_id":"slice"}],"pointer":{"project_id":"pointer"},"children_by_name":{},"labels":{}}`, + `{"exact":"ok","child":{"project_id":"child","PROJECT_ID":"overwrite"},"children":[{"project_id":"slice"}],"pointer":{"project_id":"pointer"},"children_by_name":{},"labels":{}}`, + `{"exact":"ok","child":{"project_id":"child"},"children":[{"project_id":"slice","PROJECT_ID":"overwrite"}],"pointer":{"project_id":"pointer"},"children_by_name":{},"labels":{}}`, + `{"exact":"ok","child":{"project_id":"child"},"children":[{"project_id":"slice"}],"pointer":{"project_id":"pointer","PROJECT_ID":"overwrite"},"children_by_name":{},"labels":{}}`, + `{"exact":"ok","child":{"project_id":"child"},"children":[{"project_id":"slice"}],"pointer":{"project_id":"pointer"},"children_by_name":{"map-key":{"project_id":"map-value","PROJECT_ID":"overwrite"}},"labels":{}}`, + } { + var decoded Wire + if err := Decode([]byte(body), &decoded); err == nil { + t.Fatalf("accepted case-folded alias: %s", body) + } + } +} From 2e28a829c3787d3450a43284f6db34da8b757729 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 15:11:53 +0800 Subject: [PATCH 06/25] feat: freeze inspect and decision command contracts --- internal/cli/contracts.go | 513 +++++++++++++++++++++++++++ internal/cli/contracts_test.go | 613 +++++++++++++++++++++++++++++++++ 2 files changed, 1126 insertions(+) create mode 100644 internal/cli/contracts.go create mode 100644 internal/cli/contracts_test.go diff --git a/internal/cli/contracts.go b/internal/cli/contracts.go new file mode 100644 index 0000000..84529d5 --- /dev/null +++ b/internal/cli/contracts.go @@ -0,0 +1,513 @@ +package cli + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +const ( + MaxInspectPageSize = 100 + MaxInspectQueryBytes = 256 + MaxDecisionInputBytes = 64 << 10 + MaxOpaqueCursorBytes = 4096 + MaxInspectResponseBytes = 1 << 20 +) + +const InspectExecutionTimeout = 5 * time.Second + +const ( + ContractCodeInvalidArgument = "invalid_argument" + ContractCodeGenerationMismatch = "generation_mismatch" + ContractCodeStaleCursor = "stale_cursor" + ContractCodeAnchorOutOfRange = "anchor_out_of_range" + ContractCodeResponseTooLarge = "response_too_large" + ContractCodeCandidateRevisionConflict = "candidate_revision_conflict" + ContractCodeReviewPreimageConflict = "review_preimage_conflict" + ContractCodeSessionIndexCapacityExceeded = "session_index_capacity_exceeded" + ContractCodeMigrationPreviewStale = "migration_preview_stale" +) + +// ContractError is returned when an invocation does not match a frozen CLI +// contract. Code is stable for callers; Message is intended for diagnostics. +type ContractError struct { + Code string + Message string +} + +func (e ContractError) Error() string { return e.Message } + +func contractError(parts ...string) error { + code, message := ContractCodeInvalidArgument, "invalid argument" + switch len(parts) { + case 1: + message = parts[0] + case 2: + code, message = parts[0], parts[1] + default: + panic("contractError requires a message or code and message") + } + return ContractError{Code: code, Message: message} +} + +type InspectRequest struct { + Command string + ProjectID string + Provider string + SessionID string + ExpectedGenerationID string + Cursor string + Anchor int + Limit int + QueryKind string + Query string +} + +type DecisionRequest struct { + Command string + Subcommand string + ProjectID string + Status string + ExpectedReviewSHA256 string + ExpectedGenerationID string + JobID string + ExpectedRevision int + Action string + CandidateID string +} + +type PricingRequest struct { + Command string + ProjectID string + Provider string + SessionID string + UsageRecordDigest string + ExpectedLedgerSHA256 string +} + +type SyncMigrationRequest struct { + Mode string + ProjectID string + DataDir string + ExpectedPreviewDigest string +} + +type contractFlags struct { + values map[string]string + json bool +} + +func parseContractFlags(args []string, allowed map[string]bool) (contractFlags, error) { + parsed := contractFlags{values: make(map[string]string)} + seen := make(map[string]bool) + for i := 0; i < len(args); i++ { + if !utf8.ValidString(args[i]) { + return contractFlags{}, contractError("arguments must contain valid UTF-8") + } + token := args[i] + if !strings.HasPrefix(token, "--") || token == "--" || strings.Contains(token, "=") { + return contractFlags{}, contractError("unexpected positional argument") + } + name := strings.TrimPrefix(token, "--") + if name == "" || !allowed[name] || seen[name] { + return contractFlags{}, contractError("unknown or duplicate flag") + } + seen[name] = true + if name == "json" || name == "dry-run" || name == "confirm-migration" { + if name == "json" { + parsed.json = true + } else { + parsed.values[name] = "true" + } + continue + } + if i+1 >= len(args) || args[i+1] == "" || strings.HasPrefix(args[i+1], "--") { + return contractFlags{}, contractError("flag value is required") + } + i++ + if !utf8.ValidString(args[i]) { + return contractFlags{}, contractError("arguments must contain valid UTF-8") + } + parsed.values[name] = args[i] + } + if !parsed.json { + return contractFlags{}, contractError("--json is required") + } + return parsed, nil +} + +func requireFlags(flags contractFlags, names ...string) error { + for _, name := range names { + if flags.values[name] == "" { + return contractError("required flag is missing") + } + } + return nil +} + +func safeContractID(value string) bool { + return utf8.ValidString(value) && safeReviewID(value) +} + +func requireSafeIDs(flags contractFlags, names ...string) error { + for _, name := range names { + if !safeContractID(flags.values[name]) { + return contractError("ID is empty or invalid") + } + } + return nil +} + +var bareSHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func requireBareSHA(value string) error { + if !bareSHA256Pattern.MatchString(value) { + return contractError("digest must be 64 lowercase hexadecimal characters") + } + return nil +} + +func requireDigest(value string) error { + if !digestPattern.MatchString(value) { + return contractError("digest must use sha256:<64 lowercase hex>") + } + return nil +} + +func parsePositiveInt(value string) (int, bool) { + if value == "" || (len(value) > 1 && value[0] == '0') || value[0] < '1' || value[0] > '9' { + return 0, false + } + for _, c := range value[1:] { + if c < '0' || c > '9' { + return 0, false + } + } + n, err := strconv.ParseInt(value, 10, 0) + return int(n), err == nil && n > 0 +} + +func requirePositiveInt(value string) (int, error) { + n, ok := parsePositiveInt(value) + if !ok { + return 0, contractError("integer must be a positive decimal number") + } + return n, nil +} + +func requirePageLimit(value string) (int, error) { + n, err := requirePositiveInt(value) + if err != nil || n > MaxInspectPageSize { + return 0, contractError("limit must be between 1 and 100") + } + return n, nil +} + +func requireBoundedUTF8(value string, max int, label string) error { + if !utf8.ValidString(value) || len([]byte(value)) > max { + return contractError(fmt.Sprintf("%s exceeds its UTF-8 byte limit", label)) + } + return nil +} + +func ParseInspectContract(args []string) (InspectRequest, error) { + if len(args) == 0 { + return InspectRequest{}, contractError("inspect subcommand is required") + } + switch args[0] { + case "session-summary": + return parseSessionSummaryContract(args[1:]) + case "session-events": + return parseSessionEventsContract(args[1:]) + case "session-search": + return parseSessionSearchContract(args[1:]) + default: + return InspectRequest{}, contractError("unknown inspect subcommand") + } +} + +func parseSessionSummaryContract(args []string) (InspectRequest, error) { + allowed := map[string]bool{"project-id": true, "provider": true, "session-id": true, "expected-generation-id": true, "json": true} + flags, err := parseContractFlags(args, allowed) + if err != nil { + return InspectRequest{}, err + } + if err = requireFlags(flags, "project-id", "provider", "session-id", "expected-generation-id"); err != nil { + return InspectRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "provider", "session-id", "expected-generation-id"); err != nil { + return InspectRequest{}, err + } + return InspectRequest{Command: "session-summary", ProjectID: flags.values["project-id"], Provider: flags.values["provider"], SessionID: flags.values["session-id"], ExpectedGenerationID: flags.values["expected-generation-id"]}, nil +} + +func parseSessionEventsContract(args []string) (InspectRequest, error) { + allowed := map[string]bool{"project-id": true, "provider": true, "session-id": true, "expected-generation-id": true, "cursor": true, "anchor": true, "limit": true, "json": true} + flags, err := parseContractFlags(args, allowed) + if err != nil { + return InspectRequest{}, err + } + if err = requireFlags(flags, "project-id", "provider", "session-id", "expected-generation-id", "limit"); err != nil { + return InspectRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "provider", "session-id", "expected-generation-id"); err != nil { + return InspectRequest{}, err + } + limit, err := requirePageLimit(flags.values["limit"]) + if err != nil { + return InspectRequest{}, err + } + cursor, anchor := flags.values["cursor"], flags.values["anchor"] + if cursor != "" && anchor != "" { + return InspectRequest{}, contractError("cursor and anchor are mutually exclusive") + } + if cursor != "" { + if err = requireBoundedUTF8(cursor, MaxOpaqueCursorBytes, "cursor"); err != nil { + return InspectRequest{}, err + } + } + anchorValue := 0 + if anchor != "" { + anchorValue, err = requirePositiveInt(anchor) + if err != nil { + return InspectRequest{}, err + } + } + return InspectRequest{Command: "session-events", ProjectID: flags.values["project-id"], Provider: flags.values["provider"], SessionID: flags.values["session-id"], ExpectedGenerationID: flags.values["expected-generation-id"], Cursor: cursor, Anchor: anchorValue, Limit: limit}, nil +} + +func parseSessionSearchContract(args []string) (InspectRequest, error) { + allowed := map[string]bool{"project-id": true, "expected-generation-id": true, "query-kind": true, "query": true, "cursor": true, "limit": true, "json": true} + flags, err := parseContractFlags(args, allowed) + if err != nil { + return InspectRequest{}, err + } + if err = requireFlags(flags, "project-id", "expected-generation-id", "query-kind", "query", "limit"); err != nil { + return InspectRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "expected-generation-id"); err != nil { + return InspectRequest{}, err + } + if kind := flags.values["query-kind"]; kind != "branch" && kind != "file" && kind != "error" { + return InspectRequest{}, contractError("query-kind is invalid") + } + if err = requireBoundedUTF8(flags.values["query"], MaxInspectQueryBytes, "query"); err != nil { + return InspectRequest{}, err + } + limit, err := requirePageLimit(flags.values["limit"]) + if err != nil { + return InspectRequest{}, err + } + if cursor := flags.values["cursor"]; cursor != "" { + if err = requireBoundedUTF8(cursor, MaxOpaqueCursorBytes, "cursor"); err != nil { + return InspectRequest{}, err + } + } + return InspectRequest{Command: "session-search", ProjectID: flags.values["project-id"], ExpectedGenerationID: flags.values["expected-generation-id"], QueryKind: flags.values["query-kind"], Query: flags.values["query"], Cursor: flags.values["cursor"], Limit: limit}, nil +} + +func ParseDecisionContract(args []string) (DecisionRequest, error) { + if len(args) == 0 { + return DecisionRequest{}, contractError("decisions command is required") + } + if args[0] == "candidates" { + if len(args) < 2 || args[1] != "list" { + return DecisionRequest{}, contractError("unknown decisions candidates command") + } + return parseCandidateListContract(args[2:]) + } + switch args[0] { + case "create": + return parseDecisionCreateContract(args[1:]) + case "extract": + return parseDecisionExtractContract(args[1:]) + case "candidate": + if len(args) < 2 || args[1] != "transition" { + return DecisionRequest{}, contractError("unknown candidate command") + } + return parseCandidateTransitionContract(args[2:]) + default: + return DecisionRequest{}, contractError("unknown decisions command") + } +} + +func parseCandidateListContract(args []string) (DecisionRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"project-id": true, "status": true, "json": true}) + if err != nil { + return DecisionRequest{}, err + } + if err = requireFlags(flags, "project-id"); err != nil { + return DecisionRequest{}, err + } + if err = requireSafeIDs(flags, "project-id"); err != nil { + return DecisionRequest{}, err + } + if status := flags.values["status"]; status != "" { + switch status { + case "pending", "confirmed", "ignored", "not_decision", "stale": + default: + return DecisionRequest{}, contractError("status is invalid") + } + } + return DecisionRequest{Command: "candidates", Subcommand: "list", ProjectID: flags.values["project-id"], Status: flags.values["status"]}, nil +} + +func parseDecisionCreateContract(args []string) (DecisionRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"project-id": true, "expected-review-sha256": true, "json": true}) + if err != nil { + return DecisionRequest{}, err + } + if err = requireFlags(flags, "project-id", "expected-review-sha256"); err != nil { + return DecisionRequest{}, err + } + if err = requireSafeIDs(flags, "project-id"); err != nil { + return DecisionRequest{}, err + } + if err = requireBareSHA(flags.values["expected-review-sha256"]); err != nil { + return DecisionRequest{}, err + } + return DecisionRequest{Command: "create", ProjectID: flags.values["project-id"], ExpectedReviewSHA256: flags.values["expected-review-sha256"]}, nil +} + +func parseDecisionExtractContract(args []string) (DecisionRequest, error) { + if len(args) > 0 && args[0] == "status" { + return parseExtractStatusContract(args[1:]) + } + if len(args) > 0 && args[0] == "cancel" { + return parseExtractCancelContract(args[1:]) + } + flags, err := parseContractFlags(args, map[string]bool{"project-id": true, "expected-generation-id": true, "json": true}) + if err != nil { + return DecisionRequest{}, err + } + if err = requireFlags(flags, "project-id", "expected-generation-id"); err != nil { + return DecisionRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "expected-generation-id"); err != nil { + return DecisionRequest{}, err + } + return DecisionRequest{Command: "extract", ProjectID: flags.values["project-id"], ExpectedGenerationID: flags.values["expected-generation-id"]}, nil +} + +func parseExtractStatusContract(args []string) (DecisionRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"job-id": true, "json": true}) + if err != nil { + return DecisionRequest{}, err + } + if err = requireFlags(flags, "job-id"); err != nil { + return DecisionRequest{}, err + } + if err = requireSafeIDs(flags, "job-id"); err != nil { + return DecisionRequest{}, err + } + return DecisionRequest{Command: "extract", Subcommand: "status", JobID: flags.values["job-id"]}, nil +} + +func parseExtractCancelContract(args []string) (DecisionRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"job-id": true, "expected-revision": true, "json": true}) + if err != nil { + return DecisionRequest{}, err + } + if err = requireFlags(flags, "job-id", "expected-revision"); err != nil { + return DecisionRequest{}, err + } + if err = requireSafeIDs(flags, "job-id"); err != nil { + return DecisionRequest{}, err + } + revision, err := requirePositiveInt(flags.values["expected-revision"]) + if err != nil { + return DecisionRequest{}, err + } + return DecisionRequest{Command: "extract", Subcommand: "cancel", JobID: flags.values["job-id"], ExpectedRevision: revision}, nil +} + +func parseCandidateTransitionContract(args []string) (DecisionRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"project-id": true, "candidate-id": true, "expected-revision": true, "action": true, "expected-review-sha256": true, "json": true}) + if err != nil { + return DecisionRequest{}, err + } + if err = requireFlags(flags, "project-id", "candidate-id", "expected-revision", "action", "expected-review-sha256"); err != nil { + return DecisionRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "candidate-id"); err != nil { + return DecisionRequest{}, err + } + revision, err := requirePositiveInt(flags.values["expected-revision"]) + if err != nil { + return DecisionRequest{}, err + } + switch flags.values["action"] { + case "confirm", "ignore", "not_decision", "restore": + default: + return DecisionRequest{}, contractError("action is invalid") + } + if err = requireBareSHA(flags.values["expected-review-sha256"]); err != nil { + return DecisionRequest{}, err + } + return DecisionRequest{Command: "candidate", Subcommand: "transition", ProjectID: flags.values["project-id"], CandidateID: flags.values["candidate-id"], ExpectedRevision: revision, Action: flags.values["action"], ExpectedReviewSHA256: flags.values["expected-review-sha256"]}, nil +} + +func ParsePricingContract(args []string) (PricingRequest, error) { + if len(args) == 0 || args[0] != "supplement" { + return PricingRequest{}, contractError("pricing supplement is required") + } + flags, err := parseContractFlags(args[1:], map[string]bool{"project-id": true, "provider": true, "session-id": true, "usage-record-digest": true, "expected-ledger-sha256": true, "json": true}) + if err != nil { + return PricingRequest{}, err + } + if err = requireFlags(flags, "project-id", "provider", "session-id", "usage-record-digest", "expected-ledger-sha256"); err != nil { + return PricingRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "provider", "session-id"); err != nil { + return PricingRequest{}, err + } + if err = requireDigest(flags.values["usage-record-digest"]); err != nil { + return PricingRequest{}, err + } + if err = requireBareSHA(flags.values["expected-ledger-sha256"]); err != nil { + return PricingRequest{}, err + } + return PricingRequest{Command: "supplement", ProjectID: flags.values["project-id"], Provider: flags.values["provider"], SessionID: flags.values["session-id"], UsageRecordDigest: flags.values["usage-record-digest"], ExpectedLedgerSHA256: flags.values["expected-ledger-sha256"]}, nil +} + +func ParseSyncMigrationContract(args []string) (SyncMigrationRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"dry-run": true, "confirm-migration": true, "expected-preview-digest": true, "project-id": true, "data-dir": true, "json": true}) + if err != nil { + return SyncMigrationRequest{}, err + } + dryRun, confirm := flags.values["dry-run"] == "true", flags.values["confirm-migration"] == "true" + // Boolean flags use an internal sentinel set only when the flag is present. + if !dryRun && !confirm { + return SyncMigrationRequest{}, contractError("explicit migration mode is required") + } + if dryRun && confirm { + return SyncMigrationRequest{}, contractError("migration modes are mutually exclusive") + } + if flags.values["project-id"] != "" { + if err = requireSafeIDs(flags, "project-id"); err != nil { + return SyncMigrationRequest{}, err + } + } + if flags.values["data-dir"] != "" && !utf8.ValidString(flags.values["data-dir"]) { + return SyncMigrationRequest{}, contractError("data-dir must be valid UTF-8") + } + if confirm { + if err = requireFlags(flags, "expected-preview-digest"); err != nil { + return SyncMigrationRequest{}, err + } + if err = requireDigest(flags.values["expected-preview-digest"]); err != nil { + return SyncMigrationRequest{}, err + } + } else if flags.values["expected-preview-digest"] != "" { + return SyncMigrationRequest{}, contractError("expected preview digest requires confirmation") + } + mode := "dry-run" + if confirm { + mode = "confirm-migration" + } + return SyncMigrationRequest{Mode: mode, ProjectID: flags.values["project-id"], DataDir: flags.values["data-dir"], ExpectedPreviewDigest: flags.values["expected-preview-digest"]}, nil +} diff --git a/internal/cli/contracts_test.go b/internal/cli/contracts_test.go new file mode 100644 index 0000000..42fbbf4 --- /dev/null +++ b/internal/cli/contracts_test.go @@ -0,0 +1,613 @@ +package cli + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +const contractTestSHA = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +const contractTestDigest = "sha256:" + contractTestSHA + +func contractCode(err error) string { + var contractErr ContractError + if errors.As(err, &contractErr) { + return contractErr.Code + } + var contractErrPtr *ContractError + if errors.As(err, &contractErrPtr) && contractErrPtr != nil { + return contractErrPtr.Code + } + return "" +} + +func TestParseInspectContractAcceptsExactAllowlist(t *testing.T) { + tests := [][]string{ + {"session-summary", "--project-id", "project-p", "--provider", "codex", "--session-id", "session-1", "--expected-generation-id", "generation-1", "--json"}, + {"session-events", "--project-id", "project-p", "--provider", "codex", "--session-id", "session-1", "--expected-generation-id", "generation-1", "--limit", "1", "--json"}, + {"session-events", "--json", "--limit", "100", "--anchor", "2", "--expected-generation-id", "generation-1", "--session-id", "session-1", "--provider", "codex", "--project-id", "project-p"}, + {"session-events", "--project-id", "project-p", "--provider", "codex", "--session-id", "session-1", "--expected-generation-id", "generation-1", "--cursor", "opaque", "--limit", "100", "--json"}, + {"session-search", "--project-id", "project-p", "--expected-generation-id", "generation-1", "--query-kind", "branch", "--query", "feature/login", "--limit", "1", "--json"}, + {"session-search", "--json", "--cursor", "opaque", "--limit", "100", "--query", "timeout", "--query-kind", "error", "--expected-generation-id", "generation-1", "--project-id", "project-p"}, + } + for _, args := range tests { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + if _, err := ParseInspectContract(args); err != nil { + t.Fatalf("args=%v err=%v code=%q", args, err, contractCode(err)) + } + }) + } +} + +func TestParseInspectContractRejectsExactInvalidArgv(t *testing.T) { + tooLong := strings.Repeat("q", MaxInspectQueryBytes+1) + tooLongCursor := strings.Repeat("c", MaxOpaqueCursorBytes+1) + tests := []struct { + name string + args []string + }{ + {"missing subcommand", nil}, + {"unknown subcommand", []string{"sessions", "--json"}}, + {"summary missing required", []string{"session-summary", "--project-id", "project-p", "--json"}}, + {"summary unknown flag", []string{"session-summary", "--project-id", "project-p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--input", "x", "--json"}}, + {"summary duplicate flag", []string{"session-summary", "--project-id", "project-p", "--project-id", "project-p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}}, + {"summary duplicate json", []string{"session-summary", "--project-id", "project-p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json", "--json"}}, + {"summary extra positional", []string{"session-summary", "--project-id", "project-p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json", "extra"}}, + {"summary empty id", []string{"session-summary", "--project-id", "", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}}, + {"summary unsafe id", []string{"session-summary", "--project-id", "../project", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}}, + {"summary invalid utf8", []string{"session-summary", "--project-id", string([]byte{0xff}), "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}}, + {"events missing limit", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}}, + {"events zero limit", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--limit", "0", "--json"}}, + {"events too large limit", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--limit", "101", "--json"}}, + {"events signed limit", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--limit", "+1", "--json"}}, + {"events leading zero limit", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--limit", "01", "--json"}}, + {"events mixed cursor anchor", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--cursor", "opaque", "--anchor", "2", "--limit", "100", "--json"}}, + {"events anchor zero", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--anchor", "0", "--limit", "1", "--json"}}, + {"events anchor noninteger", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--anchor", "x", "--limit", "1", "--json"}}, + {"events empty cursor", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--cursor", "", "--limit", "1", "--json"}}, + {"events oversized cursor", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--cursor", tooLongCursor, "--limit", "1", "--json"}}, + {"search invalid query kind", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "symbol", "--query", "x", "--limit", "1", "--json"}}, + {"search empty query", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", "", "--limit", "1", "--json"}}, + {"search oversized query", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", tooLong, "--limit", "1", "--json"}}, + {"search mixed invalid utf8 query", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", string([]byte{0xff}), "--limit", "1", "--json"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := ParseInspectContract(test.args); err == nil || contractCode(err) != "invalid_argument" { + t.Fatalf("args=%v err=%v code=%q", test.args, err, contractCode(err)) + } + }) + } +} + +func TestParseInspectContractRejectsMixedCursorAndAnchor(t *testing.T) { + _, err := ParseInspectContract([]string{"session-events", "--project-id", "project-p", "--provider", "codex", "--session-id", "s1", "--expected-generation-id", "g1", "--cursor", "opaque", "--anchor", "2", "--limit", "100", "--json"}) + if contractCode(err) != "invalid_argument" { + t.Fatalf("code=%q err=%v", contractCode(err), err) + } +} + +func TestParseDecisionContractAcceptsExactAllowlist(t *testing.T) { + tests := [][]string{ + {"candidates", "list", "--project-id", "project-p", "--json"}, + {"candidates", "list", "--json", "--status", "stale", "--project-id", "project-p"}, + {"create", "--project-id", "project-p", "--expected-review-sha256", contractTestSHA, "--json"}, + {"extract", "--project-id", "project-p", "--expected-generation-id", "generation-1", "--json"}, + {"extract", "status", "--job-id", "job-1", "--json"}, + {"extract", "cancel", "--job-id", "job-1", "--expected-revision", "1", "--json"}, + {"candidate", "transition", "--project-id", "project-p", "--candidate-id", "candidate-1", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"}, + {"candidate", "transition", "--json", "--expected-review-sha256", contractTestSHA, "--action", "restore", "--expected-revision", "10", "--candidate-id", "candidate-1", "--project-id", "project-p"}, + } + for _, args := range tests { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + if _, err := ParseDecisionContract(args); err != nil { + t.Fatalf("args=%v err=%v code=%q", args, err, contractCode(err)) + } + }) + } +} + +func TestParseDecisionContractRejectsExactInvalidArgv(t *testing.T) { + tests := [][]string{ + {"decisions", "--json"}, {"candidates", "status", "--project-id", "p", "--json"}, + {"candidates", "list", "--project-id", "p", "--status", "active", "--json"}, + {"candidates", "list", "--project-id", "p", "--status", "pending", "--status", "stale", "--json"}, + {"candidates", "list", "--project-id", "p", "--path", "x", "--json"}, + {"candidates", "list", "--project-id", "p", "--json", "--json"}, + {"create", "--project-id", "p", "--expected-review-sha256", contractTestDigest, "--json"}, + {"create", "--project-id", "p", "--expected-review-sha256", strings.Repeat("a", 63), "--json"}, + {"create", "--project-id", "p", "--expected-review-sha256", strings.Repeat("A", 64), "--json"}, + {"create", "--project-id", "p", "--expected-review-sha256", contractTestSHA, "--input", "file", "--json"}, + {"extract", "--project-id", "p", "--expected-generation-id", "g"}, + {"extract", "--project-id", "p", "--expected-generation-id", "g", "--json", "extra"}, + {"extract", "status", "--job-id", "job", "--json", "--project-id", "p"}, + {"extract", "cancel", "--job-id", "job", "--expected-revision", "0", "--json"}, + {"extract", "cancel", "--job-id", "job", "--expected-revision", "+1", "--json"}, + {"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA}, + {"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "approve", "--expected-review-sha256", contractTestSHA, "--json"}, + {"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--file", "x", "--json"}, + {"candidate", "transition", "--project-id", "P", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"}, + } + for _, args := range tests { + t.Run(strings.Join(args, "_"), func(t *testing.T) { + if _, err := ParseDecisionContract(args); err == nil || contractCode(err) != "invalid_argument" { + t.Fatalf("args=%v err=%v code=%q", args, err, contractCode(err)) + } + }) + } +} + +func TestParsePricingContractAcceptsAndRejectsDigests(t *testing.T) { + valid := []string{"supplement", "--project-id", "project-p", "--provider", "codex", "--session-id", "session-1", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"} + if _, err := ParsePricingContract(valid); err != nil { + t.Fatalf("valid err=%v code=%q", err, contractCode(err)) + } + for _, digest := range []string{contractTestSHA, "sha256:" + strings.Repeat("A", 64), "sha256:" + strings.Repeat("a", 63), "sha512:" + contractTestSHA} { + args := append([]string(nil), valid...) + for i := range args { + if args[i] == contractTestDigest { + args[i] = digest + } + } + if _, err := ParsePricingContract(args); err == nil || contractCode(err) != "invalid_argument" { + t.Fatalf("digest=%q err=%v code=%q", digest, err, contractCode(err)) + } + } + for _, args := range [][]string{ + {"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--data-dir", "/tmp/x", "--json"}, + {"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA}, + } { + if _, err := ParsePricingContract(args); err == nil || contractCode(err) != "invalid_argument" { + t.Fatalf("args=%v err=%v code=%q", args, err, contractCode(err)) + } + } +} + +func TestParseSyncMigrationContractAcceptsOnlyExplicitModes(t *testing.T) { + valid := [][]string{ + {"--dry-run", "--json"}, + {"--project-id", "project-p", "--data-dir", "/tmp/session-reviewer", "--dry-run", "--json"}, + {"--confirm-migration", "--expected-preview-digest", contractTestDigest, "--json"}, + {"--json", "--data-dir", "/tmp/session-reviewer", "--expected-preview-digest", contractTestDigest, "--project-id", "project-p", "--confirm-migration"}, + } + for _, args := range valid { + if _, err := ParseSyncMigrationContract(args); err != nil { + t.Fatalf("valid args=%v err=%v code=%q", args, err, contractCode(err)) + } + } + invalid := [][]string{ + nil, {}, {"--json"}, {"--dry-run"}, {"--confirm-migration", "--json"}, {"--confirm-migration", "--expected-preview-digest", contractTestSHA, "--json"}, + {"--dry-run", "--confirm-migration", "--json"}, {"--dry-run", "--project-id", "p", "--project-id", "p", "--json"}, {"--dry-run", "--unknown", "x", "--json"}, {"--dry-run", "--path", "/tmp/x", "--json"}, + {"--dry-run", "--data-dir", "", "--json"}, {"--dry-run", "--data-dir", string([]byte{0xff}), "--json"}, {"--dry-run", "--project-id", "../p", "--json"}, {"--confirm-migration", "--expected-preview-digest", contractTestDigest, "--json", "extra"}, + } + for _, args := range invalid { + if _, err := ParseSyncMigrationContract(args); err == nil || contractCode(err) != "invalid_argument" { + t.Fatalf("invalid args=%v err=%v code=%q", args, err, contractCode(err)) + } + } +} + +func TestContractConstantsAndStableErrors(t *testing.T) { + if MaxInspectPageSize != 100 || MaxInspectQueryBytes != 256 || MaxDecisionInputBytes != 64<<10 || MaxOpaqueCursorBytes != 4096 || MaxInspectResponseBytes != 1<<20 || InspectExecutionTimeout.Seconds() != 5 { + t.Fatalf("constants changed") + } + codes := map[string]string{ + "invalid argument": ContractCodeInvalidArgument, + "generation mismatch": ContractCodeGenerationMismatch, + "stale cursor": ContractCodeStaleCursor, + "anchor out of range": ContractCodeAnchorOutOfRange, + "response too large": ContractCodeResponseTooLarge, + "candidate revision conflict": ContractCodeCandidateRevisionConflict, + "review preimage conflict": ContractCodeReviewPreimageConflict, + "session index capacity exceeded": ContractCodeSessionIndexCapacityExceeded, + "migration preview stale": ContractCodeMigrationPreviewStale, + } + wantCodes := map[string]string{ + "invalid argument": "invalid_argument", + "generation mismatch": "generation_mismatch", + "stale cursor": "stale_cursor", + "anchor out of range": "anchor_out_of_range", + "response too large": "response_too_large", + "candidate revision conflict": "candidate_revision_conflict", + "review preimage conflict": "review_preimage_conflict", + "session index capacity exceeded": "session_index_capacity_exceeded", + "migration preview stale": "migration_preview_stale", + } + if !reflect.DeepEqual(codes, wantCodes) { + t.Fatalf("codes=%v want=%v", codes, wantCodes) + } + for _, code := range codes { + err := ContractError{Code: code, Message: "message"} + if err.Error() != "message" { + t.Fatalf("code=%q Error()=%q", code, err.Error()) + } + } +} + +func TestContractParsersPopulateRequestsWithoutNormalizingValues(t *testing.T) { + inspect, err := ParseInspectContract([]string{"session-events", "--project-id", "project-p", "--provider", "claude", "--session-id", "session-1", "--expected-generation-id", "generation-1", "--anchor", "7", "--limit", "25", "--json"}) + if err != nil { + t.Fatal(err) + } + wantInspect := InspectRequest{Command: "session-events", ProjectID: "project-p", Provider: "claude", SessionID: "session-1", ExpectedGenerationID: "generation-1", Anchor: 7, Limit: 25} + if !reflect.DeepEqual(inspect, wantInspect) { + t.Fatalf("inspect=%+v want=%+v", inspect, wantInspect) + } + + decision, err := ParseDecisionContract([]string{"candidate", "transition", "--project-id", "project-p", "--candidate-id", "candidate-1", "--expected-revision", "9", "--action", "not_decision", "--expected-review-sha256", contractTestSHA, "--json"}) + if err != nil { + t.Fatal(err) + } + wantDecision := DecisionRequest{Command: "candidate", Subcommand: "transition", ProjectID: "project-p", CandidateID: "candidate-1", ExpectedRevision: 9, Action: "not_decision", ExpectedReviewSHA256: contractTestSHA} + if !reflect.DeepEqual(decision, wantDecision) { + t.Fatalf("decision=%+v want=%+v", decision, wantDecision) + } + + pricing, err := ParsePricingContract([]string{"supplement", "--project-id", "project-p", "--provider", "codex", "--session-id", "session-1", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"}) + if err != nil { + t.Fatal(err) + } + wantPricing := PricingRequest{Command: "supplement", ProjectID: "project-p", Provider: "codex", SessionID: "session-1", UsageRecordDigest: contractTestDigest, ExpectedLedgerSHA256: contractTestSHA} + if !reflect.DeepEqual(pricing, wantPricing) { + t.Fatalf("pricing=%+v want=%+v", pricing, wantPricing) + } + + migration, err := ParseSyncMigrationContract([]string{"--confirm-migration", "--expected-preview-digest", contractTestDigest, "--project-id", "project-p", "--data-dir", "/tmp/session-reviewer", "--json"}) + if err != nil { + t.Fatal(err) + } + wantMigration := SyncMigrationRequest{Mode: "confirm-migration", ProjectID: "project-p", DataDir: "/tmp/session-reviewer", ExpectedPreviewDigest: contractTestDigest} + if !reflect.DeepEqual(migration, wantMigration) { + t.Fatalf("migration=%+v want=%+v", migration, wantMigration) + } +} + +func TestContractParsersRequireEveryMandatoryFlag(t *testing.T) { + tests := []struct { + name string + args []string + required []string + parse func([]string) error + }{ + {"inspect summary", []string{"session-summary", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}, []string{"--project-id", "--provider", "--session-id", "--expected-generation-id", "--json"}, inspectContractError}, + {"inspect events", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--limit", "1", "--json"}, []string{"--project-id", "--provider", "--session-id", "--expected-generation-id", "--limit", "--json"}, inspectContractError}, + {"inspect search", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", "main.go", "--limit", "1", "--json"}, []string{"--project-id", "--expected-generation-id", "--query-kind", "--query", "--limit", "--json"}, inspectContractError}, + {"candidate list", []string{"candidates", "list", "--project-id", "p", "--json"}, []string{"--project-id", "--json"}, decisionContractError}, + {"decision create", []string{"create", "--project-id", "p", "--expected-review-sha256", contractTestSHA, "--json"}, []string{"--project-id", "--expected-review-sha256", "--json"}, decisionContractError}, + {"decision extract", []string{"extract", "--project-id", "p", "--expected-generation-id", "g", "--json"}, []string{"--project-id", "--expected-generation-id", "--json"}, decisionContractError}, + {"extract status", []string{"extract", "status", "--job-id", "j", "--json"}, []string{"--job-id", "--json"}, decisionContractError}, + {"extract cancel", []string{"extract", "cancel", "--job-id", "j", "--expected-revision", "1", "--json"}, []string{"--job-id", "--expected-revision", "--json"}, decisionContractError}, + {"candidate transition", []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"}, []string{"--project-id", "--candidate-id", "--expected-revision", "--action", "--expected-review-sha256", "--json"}, decisionContractError}, + {"pricing supplement", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"}, []string{"--project-id", "--provider", "--session-id", "--usage-record-digest", "--expected-ledger-sha256", "--json"}, pricingContractError}, + {"migration dry run", []string{"--dry-run", "--json"}, []string{"--dry-run", "--json"}, migrationContractError}, + {"migration confirmation", []string{"--confirm-migration", "--expected-preview-digest", contractTestDigest, "--json"}, []string{"--confirm-migration", "--expected-preview-digest", "--json"}, migrationContractError}, + } + for _, test := range tests { + for _, required := range test.required { + t.Run(test.name+" without "+required, func(t *testing.T) { + args := removeContractFlag(t, test.args, required) + assertInvalidContract(t, test.parse(args)) + }) + } + } +} + +func TestContractParsersRejectEveryDuplicateAndUnknownFlag(t *testing.T) { + tests := []struct { + name string + args []string + parse func([]string) error + }{ + {"inspect summary", []string{"session-summary", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}, inspectContractError}, + {"inspect events cursor", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--cursor", "opaque", "--limit", "1", "--json"}, inspectContractError}, + {"inspect events anchor", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--anchor", "1", "--limit", "1", "--json"}, inspectContractError}, + {"inspect search", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", "main.go", "--cursor", "opaque", "--limit", "1", "--json"}, inspectContractError}, + {"candidate list", []string{"candidates", "list", "--project-id", "p", "--status", "pending", "--json"}, decisionContractError}, + {"decision create", []string{"create", "--project-id", "p", "--expected-review-sha256", contractTestSHA, "--json"}, decisionContractError}, + {"decision extract", []string{"extract", "--project-id", "p", "--expected-generation-id", "g", "--json"}, decisionContractError}, + {"extract status", []string{"extract", "status", "--job-id", "j", "--json"}, decisionContractError}, + {"extract cancel", []string{"extract", "cancel", "--job-id", "j", "--expected-revision", "1", "--json"}, decisionContractError}, + {"candidate transition", []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"}, decisionContractError}, + {"pricing supplement", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"}, pricingContractError}, + {"migration dry run", []string{"--dry-run", "--project-id", "p", "--data-dir", "/tmp/sr", "--json"}, migrationContractError}, + {"migration confirmation", []string{"--confirm-migration", "--expected-preview-digest", contractTestDigest, "--project-id", "p", "--data-dir", "/tmp/sr", "--json"}, migrationContractError}, + } + for _, test := range tests { + t.Run(test.name+" unknown", func(t *testing.T) { + assertInvalidContract(t, test.parse(append(append([]string(nil), test.args...), "--unknown", "value"))) + }) + for flagIndex, token := range test.args { + if !strings.HasPrefix(token, "--") { + continue + } + t.Run(test.name+" duplicate "+token, func(t *testing.T) { + duplicate := []string{token} + if token != "--json" && token != "--dry-run" && token != "--confirm-migration" { + duplicate = append(duplicate, test.args[flagIndex+1]) + } + args := append(append([]string(nil), test.args...), duplicate...) + assertInvalidContract(t, test.parse(args)) + }) + } + } +} + +func TestContractParsersRejectEveryMissingFlagValue(t *testing.T) { + tests := []struct { + name string + args []string + parse func([]string) error + }{ + {"inspect summary", []string{"session-summary", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}, inspectContractError}, + {"inspect events cursor", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--cursor", "opaque", "--limit", "1", "--json"}, inspectContractError}, + {"inspect events anchor", []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--anchor", "1", "--limit", "1", "--json"}, inspectContractError}, + {"inspect search", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", "main.go", "--cursor", "opaque", "--limit", "1", "--json"}, inspectContractError}, + {"candidate list", []string{"candidates", "list", "--project-id", "p", "--status", "pending", "--json"}, decisionContractError}, + {"decision create", []string{"create", "--project-id", "p", "--expected-review-sha256", contractTestSHA, "--json"}, decisionContractError}, + {"decision extract", []string{"extract", "--project-id", "p", "--expected-generation-id", "g", "--json"}, decisionContractError}, + {"extract status", []string{"extract", "status", "--job-id", "j", "--json"}, decisionContractError}, + {"extract cancel", []string{"extract", "cancel", "--job-id", "j", "--expected-revision", "1", "--json"}, decisionContractError}, + {"candidate transition", []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"}, decisionContractError}, + {"pricing supplement", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"}, pricingContractError}, + {"migration dry run", []string{"--dry-run", "--project-id", "p", "--data-dir", "/tmp/sr", "--json"}, migrationContractError}, + {"migration confirmation", []string{"--confirm-migration", "--expected-preview-digest", contractTestDigest, "--project-id", "p", "--data-dir", "/tmp/sr", "--json"}, migrationContractError}, + } + for _, test := range tests { + for i, token := range test.args { + if !strings.HasPrefix(token, "--") || token == "--json" || token == "--dry-run" || token == "--confirm-migration" { + continue + } + t.Run(test.name+" "+token, func(t *testing.T) { + args := append(append([]string(nil), test.args[:i+1]...), test.args[i+2:]...) + assertInvalidContract(t, test.parse(args)) + }) + } + } +} + +func TestContractParsersRejectWrongCommandShapes(t *testing.T) { + tests := []struct { + name string + args []string + parse func([]string) error + }{ + {"inspect missing command", nil, inspectContractError}, + {"inspect unknown command", []string{"summary", "--json"}, inspectContractError}, + {"decisions missing command", nil, decisionContractError}, + {"decisions unknown command", []string{"decisions", "--json"}, decisionContractError}, + {"candidates missing subcommand", []string{"candidates", "--json"}, decisionContractError}, + {"candidates unknown subcommand", []string{"candidates", "status", "--json"}, decisionContractError}, + {"candidate missing subcommand", []string{"candidate", "--json"}, decisionContractError}, + {"candidate unknown subcommand", []string{"candidate", "confirm", "--json"}, decisionContractError}, + {"extract unknown positional subcommand", []string{"extract", "start", "--json"}, decisionContractError}, + {"pricing missing command", nil, pricingContractError}, + {"pricing unknown command", []string{"estimate", "--json"}, pricingContractError}, + {"pricing extra subcommand", []string{"supplement", "status", "--json"}, pricingContractError}, + {"migration positional mode", []string{"dry-run", "--json"}, migrationContractError}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { assertInvalidContract(t, test.parse(test.args)) }) + } +} + +func TestContractParsersAcceptAllFrozenEnums(t *testing.T) { + for _, kind := range []string{"branch", "file", "error"} { + args := []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", kind, "--query", "x", "--limit", "1", "--json"} + if err := inspectContractError(args); err != nil { + t.Fatalf("query-kind=%q err=%v", kind, err) + } + } + for _, status := range []string{"pending", "confirmed", "ignored", "not_decision", "stale"} { + args := []string{"candidates", "list", "--project-id", "p", "--status", status, "--json"} + if err := decisionContractError(args); err != nil { + t.Fatalf("status=%q err=%v", status, err) + } + } + for _, action := range []string{"confirm", "ignore", "not_decision", "restore"} { + args := []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", action, "--expected-review-sha256", contractTestSHA, "--json"} + if err := decisionContractError(args); err != nil { + t.Fatalf("action=%q err=%v", action, err) + } + } +} + +func TestContractParsersEnforceSafeIDOnEveryIDFlag(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + parse func([]string) error + }{ + {"inspect summary", []string{"session-summary", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"}, []string{"--project-id", "--provider", "--session-id", "--expected-generation-id"}, inspectContractError}, + {"inspect search", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", "x", "--limit", "1", "--json"}, []string{"--project-id", "--expected-generation-id"}, inspectContractError}, + {"candidate list", []string{"candidates", "list", "--project-id", "p", "--json"}, []string{"--project-id"}, decisionContractError}, + {"decision extract", []string{"extract", "--project-id", "p", "--expected-generation-id", "g", "--json"}, []string{"--project-id", "--expected-generation-id"}, decisionContractError}, + {"extract status", []string{"extract", "status", "--job-id", "j", "--json"}, []string{"--job-id"}, decisionContractError}, + {"candidate transition", []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"}, []string{"--project-id", "--candidate-id"}, decisionContractError}, + {"pricing supplement", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"}, []string{"--project-id", "--provider", "--session-id"}, pricingContractError}, + {"migration", []string{"--dry-run", "--project-id", "p", "--json"}, []string{"--project-id"}, migrationContractError}, + } + for _, test := range tests { + for _, flag := range test.flags { + t.Run(test.name+" "+flag, func(t *testing.T) { + args := replaceContractFlagValue(t, test.args, flag, "../unsafe") + assertInvalidContract(t, test.parse(args)) + }) + } + } + for _, value := range []string{"", "Uppercase", strings.Repeat("a", 129), string([]byte{0xff})} { + args := []string{"session-summary", "--project-id", value, "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"} + assertInvalidContract(t, inspectContractError(args)) + } + validMaxID := strings.Repeat("a", 128) + args := []string{"session-summary", "--project-id", validMaxID, "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--json"} + if err := inspectContractError(args); err != nil { + t.Fatalf("128-byte safe ID err=%v", err) + } +} + +func TestContractParsersEnforceIntegerAndUTF8ByteBounds(t *testing.T) { + validQuery := strings.Repeat("界", 85) + "a" + invalidQuery := strings.Repeat("界", 85) + "ab" + for _, query := range []string{validQuery, strings.Repeat("q", MaxInspectQueryBytes)} { + args := []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "error", "--query", query, "--limit", "100", "--json"} + if err := inspectContractError(args); err != nil { + t.Fatalf("valid %d-byte query err=%v", len(query), err) + } + } + for _, query := range []string{invalidQuery, string([]byte{0xff})} { + args := []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "error", "--query", query, "--limit", "1", "--json"} + assertInvalidContract(t, inspectContractError(args)) + } + + validCursor := strings.Repeat("界", 1365) + "a" + invalidCursor := strings.Repeat("界", 1365) + "ab" + for _, cursor := range []string{validCursor, strings.Repeat("c", MaxOpaqueCursorBytes)} { + args := []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--cursor", cursor, "--limit", "1", "--json"} + if err := inspectContractError(args); err != nil { + t.Fatalf("valid %d-byte cursor err=%v", len(cursor), err) + } + } + for _, cursor := range []string{invalidCursor, string([]byte{0xff})} { + args := []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--cursor", cursor, "--limit", "1", "--json"} + assertInvalidContract(t, inspectContractError(args)) + } + + for _, value := range []string{"0", "-1", "+1", "01", "1.0", strings.Repeat("9", 100)} { + events := []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--anchor", value, "--limit", "1", "--json"} + assertInvalidContract(t, inspectContractError(events)) + cancel := []string{"extract", "cancel", "--job-id", "j", "--expected-revision", value, "--json"} + assertInvalidContract(t, decisionContractError(cancel)) + transition := []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", value, "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"} + assertInvalidContract(t, decisionContractError(transition)) + } + for _, limit := range []string{"0", "101", "-1", "+1", "01", "1.0", strings.Repeat("9", 100)} { + events := []string{"session-events", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--limit", limit, "--json"} + assertInvalidContract(t, inspectContractError(events)) + search := []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", "x", "--limit", limit, "--json"} + assertInvalidContract(t, inspectContractError(search)) + } +} + +func TestContractParsersEnforceEveryDigestFormat(t *testing.T) { + tests := []struct { + name string + args []string + flag string + valid string + invalid []string + parse func([]string) error + }{ + {"decision create review sha", []string{"create", "--project-id", "p", "--expected-review-sha256", contractTestSHA, "--json"}, "--expected-review-sha256", contractTestSHA, []string{contractTestDigest, strings.Repeat("A", 64), strings.Repeat("a", 63)}, decisionContractError}, + {"candidate transition review sha", []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-revision", "1", "--action", "confirm", "--expected-review-sha256", contractTestSHA, "--json"}, "--expected-review-sha256", contractTestSHA, []string{contractTestDigest, strings.Repeat("A", 64), strings.Repeat("a", 65)}, decisionContractError}, + {"pricing ledger sha", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"}, "--expected-ledger-sha256", contractTestSHA, []string{contractTestDigest, strings.Repeat("A", 64), strings.Repeat("a", 63)}, pricingContractError}, + {"pricing usage digest", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--json"}, "--usage-record-digest", contractTestDigest, []string{contractTestSHA, "sha256:" + strings.Repeat("A", 64), "sha256:" + strings.Repeat("a", 63), "sha512:" + contractTestSHA}, pricingContractError}, + {"migration preview digest", []string{"--confirm-migration", "--expected-preview-digest", contractTestDigest, "--json"}, "--expected-preview-digest", contractTestDigest, []string{contractTestSHA, "sha256:" + strings.Repeat("A", 64), "sha256:" + strings.Repeat("a", 65), "sha512:" + contractTestSHA}, migrationContractError}, + } + for _, test := range tests { + if err := test.parse(replaceContractFlagValue(t, test.args, test.flag, test.valid)); err != nil { + t.Fatalf("%s valid err=%v", test.name, err) + } + for _, value := range test.invalid { + t.Run(test.name+" "+value, func(t *testing.T) { + assertInvalidContract(t, test.parse(replaceContractFlagValue(t, test.args, test.flag, value))) + }) + } + } +} + +func TestContractParsersRejectForbiddenInputSurfacesAndPositionals(t *testing.T) { + tests := []struct { + name string + args []string + parse func([]string) error + }{ + {"inspect file", []string{"session-summary", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--expected-generation-id", "g", "--file", "input.json", "--json"}, inspectContractError}, + {"inspect input", []string{"session-search", "--project-id", "p", "--expected-generation-id", "g", "--query-kind", "file", "--query", "x", "--input", "input.json", "--limit", "1", "--json"}, inspectContractError}, + {"decision path", []string{"create", "--project-id", "p", "--expected-review-sha256", contractTestSHA, "--path", "input.json", "--json"}, decisionContractError}, + {"decision positional", []string{"extract", "--project-id", "p", "--expected-generation-id", "g", "payload.md", "--json"}, decisionContractError}, + {"pricing data dir", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "--data-dir", "/tmp/sr", "--json"}, pricingContractError}, + {"pricing positional", []string{"supplement", "--project-id", "p", "--provider", "codex", "--session-id", "s", "--usage-record-digest", contractTestDigest, "--expected-ledger-sha256", contractTestSHA, "payload.json", "--json"}, pricingContractError}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { assertInvalidContract(t, test.parse(test.args)) }) + } +} + +func TestParseSyncMigrationContractHasNoFilesystemSideEffects(t *testing.T) { + root := t.TempDir() + dataDir := filepath.Join(root, "must-not-be-created") + args := []string{"--dry-run", "--data-dir", dataDir, "--json"} + original := append([]string(nil), args...) + if _, err := ParseSyncMigrationContract(args); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(args, original) { + t.Fatalf("args mutated: got=%v want=%v", args, original) + } + if _, err := os.Stat(dataDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("parser touched data-dir: %v", err) + } +} + +func inspectContractError(args []string) error { + _, err := ParseInspectContract(args) + return err +} + +func decisionContractError(args []string) error { + _, err := ParseDecisionContract(args) + return err +} + +func pricingContractError(args []string) error { + _, err := ParsePricingContract(args) + return err +} + +func migrationContractError(args []string) error { + _, err := ParseSyncMigrationContract(args) + return err +} + +func assertInvalidContract(t *testing.T, err error) { + t.Helper() + if err == nil || contractCode(err) != ContractCodeInvalidArgument { + t.Fatalf("err=%v code=%q", err, contractCode(err)) + } +} + +func removeContractFlag(t *testing.T, args []string, flag string) []string { + t.Helper() + for i, token := range args { + if token != flag { + continue + } + width := 2 + if flag == "--json" || flag == "--dry-run" || flag == "--confirm-migration" { + width = 1 + } + return append(append([]string(nil), args[:i]...), args[i+width:]...) + } + t.Fatalf("flag %q not found in %v", flag, args) + return nil +} + +func replaceContractFlagValue(t *testing.T, args []string, flag, value string) []string { + t.Helper() + result := append([]string(nil), args...) + for i, token := range result { + if token == flag { + if i+1 >= len(result) { + t.Fatalf("flag %q has no value in %v", flag, args) + } + result[i+1] = value + return result + } + } + t.Fatalf("flag %q not found in %v", flag, args) + return nil +} From 1d50622e71869450af9cd395d1ea549e6ca8a35e Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 15:39:09 +0800 Subject: [PATCH 07/25] feat: mirror v4 contracts in Obsidian --- obsidian-plugin/src/contracts/review-v4.ts | 455 ++++++ obsidian-plugin/src/data/contracts-v4.ts | 1414 +++++++++++++++++ obsidian-plugin/tests/contracts-v4.test.ts | 284 ++++ .../v4/agent-annotation-v1.invalid.json | 3 + .../v4/agent-annotation-v1.valid.json | 3 + .../v4/machine-ledger-v4.invalid.json | 2 + .../fixtures/v4/machine-ledger-v4.valid.json | 5 + .../v4/pricing-snapshot-v1.invalid.json | 3 + .../v4/pricing-snapshot-v1.valid.json | 4 + .../v4/pricing-supplement-v1.invalid.json | 3 + .../v4/pricing-supplement-v1.valid.json | 3 + .../v4/review-presentation-v4.invalid.json | 5 + .../v4/review-presentation-v4.valid.json | 5 + .../v4/session-event-page-v1.invalid.json | 4 + .../v4/session-event-page-v1.valid.json | 4 + .../fixtures/v4/session-index-v1.invalid.json | 12 + .../fixtures/v4/session-index-v1.valid.json | 32 + .../v4/session-summary-v1.invalid.json | 5 + .../fixtures/v4/session-summary-v1.valid.json | 5 + 19 files changed, 2251 insertions(+) create mode 100644 obsidian-plugin/src/contracts/review-v4.ts create mode 100644 obsidian-plugin/src/data/contracts-v4.ts create mode 100644 obsidian-plugin/tests/contracts-v4.test.ts create mode 100644 obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/session-event-page-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/session-event-page-v1.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/session-index-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/session-index-v1.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/session-summary-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/session-summary-v1.valid.json diff --git a/obsidian-plugin/src/contracts/review-v4.ts b/obsidian-plugin/src/contracts/review-v4.ts new file mode 100644 index 0000000..4546b77 --- /dev/null +++ b/obsidian-plugin/src/contracts/review-v4.ts @@ -0,0 +1,455 @@ +export type ViewKind = "evolution" | "decisions" | "sessions" | "usage"; + +export type SessionIdentity = Readonly<{ provider: string; sessionId: string }>; + +export type ProcessingState = "complete" | "partial" | "error" | "unprocessed"; +export type SourceAvailability = "available" | "unavailable"; +export type DecisionStatus = "active" | "superseded" | "archived"; +export type CandidateStatus = "pending" | "confirmed" | "ignored" | "not_decision" | "stale"; +export type PriceStatus = + | "pending" + | "current" + | "promotion" + | "stale_estimate" + | "manual_supplement" + | "ambiguous" + | "legacy_unverified" + | "superseded"; + +export interface SessionReferenceV4 { + provider: string; + session_id: string; +} + +export interface CurrentStateV4 { + goal: string; + stage: string; + status: string; + next_action: string; + last_verification: string; +} + +export interface TimelineEntryV4 { + id: string; + generation_id: string; + occurred_at: string; + kind: string; + title: string; + summary: string; + decision_ids: string[]; +} + +export interface DecisionV4 { + id: string; + kind: "decision" | "agreement"; + occurred_at: string; + title: string; + rationale: string; + impact: string; + status: DecisionStatus; + reevaluate_when: string; + supersedes: string[]; + milestone_ids: string[]; + session_refs: SessionReferenceV4[]; + provenance: "human_created" | "migrated" | "ai_candidate_confirmed"; + pinned: boolean; + revision: number; +} + +export interface RiskV4 { + id: string; + title: string; + status: string; + detail: string; +} + +export interface OpenLoopV4 { + id: string; + title: string; + status: string; + question: string; + next_experiment: string; + completion_criterion: string; +} + +export interface HumanPatchV4 { + entity_id: string; + field: string; + operation: "set" | "suppress" | "restore_default"; + value?: string; + values?: string[]; + base_generated_hash: string; +} + +export interface GeneratedBaselineV4 { + generation_id: string; + entity_id: string; + field: string; + kind: string; + value?: string; + values?: string[]; + generated_hash: string; +} + +export interface ReviewPresentationV4 { + schema_version: 4; + minimum_reader_version: "0.4.0"; + minimum_writer_version: "0.4.0"; + project_id: string; + generation_id: string; + project_view_digest: string; + revision: number; + current_state: CurrentStateV4; + timeline: TimelineEntryV4[]; + decisions: DecisionV4[]; + risks: RiskV4[]; + open_loops: OpenLoopV4[]; + human_patches: HumanPatchV4[]; + orphan_patches: HumanPatchV4[]; + generated_baselines: GeneratedBaselineV4[]; +} + +export interface PricingRatesV1 { + input: number | null; + cached_input: number | null; + cache_write_input: number | null; + output: number | null; + reasoning_output: number | null; +} + +export interface BillableQuantitiesV1 { + input: number; + cached_input: number; + cache_write_input: number; + output: number; + reasoning_output: number; +} + +export interface PricingLineCostsV1 { + input: number | null; + cached_input: number | null; + cache_write_input: number | null; + output: number | null; + reasoning_output: number | null; +} + +export interface PricingSnapshotV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + snapshot_id: string; + project_id: string; + provider: string; + session_id: string; + usage_record_digest: string; + billing_host: string; + billed_model_id: string; + billing_mode: string; + billing_rule_version: string; + region: string | null; + priced_at: string; + created_at: string; + status: PriceStatus; + modelpricewatch_listing_id: string | null; + source_kind: "modelpricewatch" | "official" | "manual" | "unresolved"; + source_url: string | null; + detail_url: string | null; + source_last_updated: string | null; + retrieved_at: string | null; + promo: boolean; + promo_until: string | null; + rates: PricingRatesV1; + billable_quantities: BillableQuantitiesV1; + line_costs_usd: PricingLineCostsV1; + missing_billing_dimensions: string[]; + known_subtotal_usd: number; + total_cost_usd: number | null; + pricing_complete: boolean; + supersedes_snapshot_id: string | null; + audit_reason: string; +} + +export interface LedgerAccountingModelV4 { + model: string; + total_tokens: number; + total_cost_usd: number | null; +} + +export interface LedgerAccountingV4 { + total_duration_ms: number; + total_tokens: number; + total_cost_usd: number | null; + models: LedgerAccountingModelV4[]; +} + +export interface LedgerSessionV4 { + provider: string; + session_id: string; + processing_state: ProcessingState; + source_availability: SourceAvailability; + session_view_digest: string | null; + usage_record_digest: string | null; +} + +export interface SyncHashesV4 { + review_sha256: string; + history_sha256: string; + ledger_sha256: string; + session_index_digest: string; +} + +export interface MachineLedgerV4 { + schema_version: 4; + minimum_reader_version: "0.4.0"; + minimum_writer_version: "0.4.0"; + project_id: string; + generation_id: string; + project_view_digest: string; + accepted_revision: number; + review_sha256: string; + history_sha256: string; + accounting: LedgerAccountingV4; + sessions: LedgerSessionV4[]; + human_patches: HumanPatchV4[]; + orphan_patches: HumanPatchV4[]; + generated_baselines: GeneratedBaselineV4[]; + pricing_snapshots: PricingSnapshotV1[]; + current_pricing_snapshot_ids: string[]; + sync_hashes: SyncHashesV4; +} + +export interface CoverageV1 { + seen: number; + indexed: number; + collapsed: number; + unprojected: number; + undecodable: number; + truncated: number; +} + +export interface SessionIndexCoverageV1 { + total: number; + complete: number; + partial: number; + error: number; + unprocessed: number; + source_available: number; + source_unavailable: number; + started_at_known: number; + ended_at_known: number; + usage_known: number; +} + +export interface SessionFactCountsV1 { + file_change: number; + command: number; + verification: number; + error: number; + artifact: number; +} + +export type SessionStateReasonCodeV1 = + | "not_discovered" + | "duplicate_candidate" + | "freeze_terminal" + | "malformed_source_records" + | "unsupported_source_records" + | "source_missing" + | "source_unreadable" + | "source_ambiguous" + | "source_unsupported" + | "source_unavailable" + | "partial_observations" + | "unprojected_facts" + | "undecodable_facts" + | "scan_cancelled"; + +export interface SessionIndexEntryV1 { + provider: string; + session_id: string; + processing_state: ProcessingState; + state_reason_codes: SessionStateReasonCodeV1[]; + source_availability: SourceAvailability; + source_terminal_state: string | null; + started_at: string; + ended_at: string; + duration_ms: number | null; + warning_count: number; + record_count: number | null; + indexed_event_count: number; + coverage: CoverageV1; + fact_counts: SessionFactCountsV1; + session_view_digest: string | null; + usage_record_digest: string | null; + summary_digest: string | null; + last_seen_generation_id: string | null; + last_successful_generation_id: string | null; +} + +export interface SessionIndexV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + digest: string; + project_id: string; + generation_id: string; + project_view_digest: string; + generated_at: string; + sort_version: "started-at-desc-null-last-provider-session-v1"; + coverage: SessionIndexCoverageV1; + sessions: SessionIndexEntryV1[]; +} + +export interface SessionSummaryEntryV1 { + occurred_at: string; + sequence: number; + revision_id: string; + text: string; + source_revision_ids: string[]; +} + +export interface SessionSummaryErrorEntryV1 extends SessionSummaryEntryV1 { + code: string; +} + +export interface SessionSummaryBlockV1 { + total: number; + shown: number; + omitted: number; + coverage: CoverageV1; + items: SessionSummaryEntryV1[]; +} + +export interface SessionSummaryErrorBlockV1 { + total: number; + shown: number; + omitted: number; + coverage: CoverageV1; + items: SessionSummaryErrorEntryV1[]; +} + +export interface SessionSummaryRulesV1 { + rule_id: string; + rule_version: string; + dependency_digests: string[]; +} + +export interface SessionSummaryV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + project_id: string; + provider: string; + session_id: string; + generation_id: string; + session_view_digest: string; + phase_boundaries: SessionSummaryBlockV1; + key_operations: SessionSummaryBlockV1; + verification_results: SessionSummaryBlockV1; + errors: SessionSummaryErrorBlockV1; + unresolved_questions: SessionSummaryBlockV1; + rules: SessionSummaryRulesV1; + coverage: CoverageV1; +} + +export type SessionEventKindV1 = + | "message" + | "tool_call" + | "tool_result" + | "cwd_change" + | "usage" + | "skip" + | "file_change" + | "command" + | "verification" + | "error" + | "artifact"; + +export interface SessionEventItemV1 { + kind: SessionEventKindV1; + excerpt: string; + revision_id: string; + sequence: number; + occurred_at: string; +} + +export interface SessionEventPageV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + project_id: string; + provider: string; + session_id: string; + generation_id: string; + session_view_digest: string; + total: number; + range_start: number; + range_end: number; + items: SessionEventItemV1[]; + previous_cursor: string | null; + next_cursor: string | null; + first_cursor: string | null; + last_cursor: string | null; + coverage: CoverageV1; +} + +export interface AnnotationDependencyV1 { + kind: "observation" | "session_view"; + revision_id: string; + digest: string; +} + +export interface AgentAnnotationEntryV1 { + id: string; + project_id: string; + entity_id: string; + field: string; + status: CandidateStatus; + text: string; + generation_id: string; + schema_version: 1; + analysis_profile: string; + agent_run_id: string; + dependencies: AnnotationDependencyV1[]; + revision: number; + created_at: string; + confirmed_decision_id: string | null; +} + +export interface AnnotationExtractionRunV1 { + run_id: string; + project_id: string; + status: "pending" | "running" | "completed" | "failed" | "cancelled"; + extractor_version: string; + prompt_schema_version: string; + dependency_digests: string[]; + created_at: string; + updated_at: string; +} + +export interface AgentAnnotationV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + project_id: string; + annotations: AgentAnnotationEntryV1[]; + extraction_runs: AnnotationExtractionRunV1[]; +} + +export type CandidateListV1 = AgentAnnotationV1; + +export interface PricingSupplementV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + project_id: string; + provider: string; + session_id: string; + usage_record_digest: string; + billing_host: string; + billed_model_id: string; + billing_mode: string; + billing_rule_version: string; + region: string | null; + effective_from: string; + effective_until: string | null; + rates: PricingRatesV1; + source_url: string; + detail_url: string | null; + audit_reason: string; + supersedes_snapshot_id: string | null; +} diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts new file mode 100644 index 0000000..02009a1 --- /dev/null +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -0,0 +1,1414 @@ +import { sha256Text } from "./hash"; +import type { + AgentAnnotationEntryV1, + AgentAnnotationV1, + AnnotationDependencyV1, + AnnotationExtractionRunV1, + BillableQuantitiesV1, + CandidateListV1, + CoverageV1, + DecisionV4, + GeneratedBaselineV4, + HumanPatchV4, + LedgerAccountingModelV4, + LedgerAccountingV4, + LedgerSessionV4, + MachineLedgerV4, + PricingLineCostsV1, + PricingRatesV1, + PricingSnapshotV1, + PricingSupplementV1, + ReviewPresentationV4, + SessionEventItemV1, + SessionEventPageV1, + SessionFactCountsV1, + SessionIndexCoverageV1, + SessionIndexEntryV1, + SessionIndexV1, + SessionReferenceV4, + SessionSummaryBlockV1, + SessionSummaryEntryV1, + SessionSummaryErrorBlockV1, + SessionSummaryErrorEntryV1, + SessionSummaryRulesV1, + SessionSummaryV1, + TimelineEntryV4 +} from "../contracts/review-v4"; + +const MAX_JSON_BYTES = 64 << 20; +const MAX_SAFE = Number.MAX_SAFE_INTEGER; +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/; +const DIGEST = /^sha256:[0-9a-f]{64}$/; +const SHA256 = /^[0-9a-f]{64}$/; +const ZERO_DIGEST = `sha256:${"0".repeat(64)}`; +const ZERO_SHA256 = "0".repeat(64); +const SORT_VERSION = "started-at-desc-null-last-provider-session-v1"; +const COVERAGE_KEYS = ["seen", "indexed", "collapsed", "unprojected", "undecodable", "truncated"] as const; +const PRICE_DIMENSIONS = ["input", "cached_input", "cache_write_input", "output", "reasoning_output"] as const; + +type JsonObject = Record; + +export function parseReviewPresentationV4(source: string): ReviewPresentationV4 { + const row = documentObject(source, "review presentation"); + exact(row, "$", [ + "schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", + "project_view_digest", "revision", "current_state", "timeline", "decisions", "risks", "open_loops", + "human_patches", "orphan_patches", "generated_baselines" + ]); + constant(row.schema_version, 4, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + version(row.minimum_writer_version, "$.minimum_writer_version"); + const projectID = id(row.project_id, "$.project_id"); + void projectID; + const generationID = id(row.generation_id, "$.generation_id"); + digest(row.project_view_digest, "$.project_view_digest"); + integer(row.revision, "$.revision"); + + const current = object(row.current_state, "$.current_state"); + exact(current, "$.current_state", ["goal", "stage", "status", "next_action", "last_verification"]); + for (const key of ["goal", "stage", "status", "next_action", "last_verification"] as const) { + text(current[key], `$.current_state.${key}`, 16384); + } + + const timeline = boundedArray(row.timeline, "$.timeline", 65536); + const timelineIDs = new Set(); + for (let index = 0; index < timeline.length; index += 1) { + const item = parseTimeline(timeline[index], `$.timeline[${index}]`, generationID); + addUnique(timelineIDs, item.id, "timeline identity"); + } + + const decisions = boundedArray(row.decisions, "$.decisions", 65536); + const decisionByID = new Map(); + for (let index = 0; index < decisions.length; index += 1) { + const item = parseDecision(decisions[index], `$.decisions[${index}]`); + if (decisionByID.has(item.id)) throw new Error(`duplicate decision "${item.id}"`); + decisionByID.set(item.id, item); + } + const successors = new Map(); + for (const item of decisionByID.values()) { + for (const target of item.supersedes) { + if (target === item.id) throw new Error("decision cannot supersede itself"); + if (!decisionByID.has(target)) throw new Error(`decision "${item.id}" supersedes missing "${target}"`); + successors.set(target, (successors.get(target) ?? 0) + 1); + } + } + if (decisionCycle(decisionByID)) throw new Error("decision supersession graph contains cycle"); + for (const item of decisionByID.values()) { + if (item.status === "superseded" && !successors.has(item.id)) { + throw new Error(`superseded decision "${item.id}" has no successor`); + } + for (const milestone of item.milestone_ids) { + if (!timelineIDs.has(milestone)) throw new Error(`decision references missing milestone "${milestone}"`); + } + } + for (let index = 0; index < timeline.length; index += 1) { + const item = timeline[index] as TimelineEntryV4; + for (const decisionID of item.decision_ids) { + if (!decisionByID.has(decisionID)) throw new Error(`timeline references missing decision "${decisionID}"`); + } + } + + parseUniqueEntityArray(row.risks, "$.risks", 65536, ["id", "title", "status", "detail"], + ["title", "status", "detail"]); + parseUniqueEntityArray(row.open_loops, "$.open_loops", 65536, + ["id", "title", "status", "question", "next_experiment", "completion_criterion"], + ["title", "status", "question", "next_experiment", "completion_criterion"]); + parsePatchArray(row.human_patches, "$.human_patches"); + parsePatchArray(row.orphan_patches, "$.orphan_patches"); + parseBaselineArray(row.generated_baselines, "$.generated_baselines"); + return row as unknown as ReviewPresentationV4; +} + +export function parseMachineLedgerV4(source: string): MachineLedgerV4 { + const row = documentObject(source, "machine ledger"); + exact(row, "$", [ + "schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", + "project_view_digest", "accepted_revision", "review_sha256", "history_sha256", "accounting", "sessions", + "human_patches", "orphan_patches", "generated_baselines", "pricing_snapshots", + "current_pricing_snapshot_ids", "sync_hashes" + ]); + constant(row.schema_version, 4, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + version(row.minimum_writer_version, "$.minimum_writer_version"); + const projectID = id(row.project_id, "$.project_id"); + id(row.generation_id, "$.generation_id"); + digest(row.project_view_digest, "$.project_view_digest"); + integer(row.accepted_revision, "$.accepted_revision"); + const reviewHash = sha256(row.review_sha256, "$.review_sha256"); + const historyHash = sha256(row.history_sha256, "$.history_sha256"); + + const accounting = parseLedgerAccounting(row.accounting, "$.accounting"); + const sessions = boundedArray(row.sessions, "$.sessions", 65536); + const identities = new Set(); + for (let index = 0; index < sessions.length; index += 1) { + const session = parseLedgerSession(sessions[index], `$.sessions[${index}]`); + addUnique(identities, identityKey(session.provider, session.session_id), "ledger session identity"); + } + parsePatchArray(row.human_patches, "$.human_patches"); + parsePatchArray(row.orphan_patches, "$.orphan_patches"); + parseBaselineArray(row.generated_baselines, "$.generated_baselines"); + + const pricingRows = boundedArray(row.pricing_snapshots, "$.pricing_snapshots", 65536); + const pricingByID = new Map(); + for (let index = 0; index < pricingRows.length; index += 1) { + const snapshot = validatePricingSnapshot(pricingRows[index], `$.pricing_snapshots[${index}]`); + if (snapshot.project_id !== projectID) throw new Error("pricing snapshot project mismatch"); + if (pricingByID.has(snapshot.snapshot_id)) throw new Error(`duplicate pricing snapshot "${snapshot.snapshot_id}"`); + pricingByID.set(snapshot.snapshot_id, snapshot); + } + const currentIDs = idArray(row.current_pricing_snapshot_ids, "$.current_pricing_snapshot_ids", 65536); + const seenCurrent = new Set(); + let currentPricingIncomplete = false; + for (const snapshotID of currentIDs) { + addUnique(seenCurrent, snapshotID, "current pricing snapshot reference"); + const snapshot = pricingByID.get(snapshotID); + if (!snapshot || snapshot.status === "superseded") throw new Error("invalid current pricing snapshot reference"); + currentPricingIncomplete ||= !snapshot.pricing_complete; + } + if (currentPricingIncomplete && accounting.total_cost_usd !== null) { + throw new Error("aggregate price must be null when a current snapshot is incomplete"); + } + + const sync = object(row.sync_hashes, "$.sync_hashes"); + exact(sync, "$.sync_hashes", ["review_sha256", "history_sha256", "ledger_sha256", "session_index_digest"]); + const syncReview = sha256(sync.review_sha256, "$.sync_hashes.review_sha256"); + const syncHistory = sha256(sync.history_sha256, "$.sync_hashes.history_sha256"); + const selfHash = sha256(sync.ledger_sha256, "$.sync_hashes.ledger_sha256"); + digest(sync.session_index_digest, "$.sync_hashes.session_index_digest"); + if (reviewHash !== syncReview || historyHash !== syncHistory) { + throw new Error("top-level and synchronization hashes disagree"); + } + + const ledger = row as unknown as MachineLedgerV4; + if (selfHash !== ZERO_SHA256 && canonicalLedgerSHA256(ledger) !== selfHash) { + throw new Error("machine ledger self digest mismatch"); + } + return ledger; +} + +export function parseSessionIndexV1(source: string): SessionIndexV1 { + const row = documentObject(source, "session index"); + exact(row, "$", [ + "schema_version", "minimum_reader_version", "digest", "project_id", "generation_id", "project_view_digest", + "generated_at", "sort_version", "coverage", "sessions" + ]); + constant(row.schema_version, 1, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + const claimedDigest = digest(row.digest, "$.digest"); + id(row.project_id, "$.project_id"); + id(row.generation_id, "$.generation_id"); + digest(row.project_view_digest, "$.project_view_digest"); + text(row.generated_at, "$.generated_at", 128, true); + constant(row.sort_version, SORT_VERSION, "$.sort_version"); + const claimedCoverage = parseIndexCoverage(row.coverage, "$.coverage"); + const sessions = boundedArray(row.sessions, "$.sessions", 65536); + const identities = new Set(); + const calculated: SessionIndexCoverageV1 = { + total: sessions.length, + complete: 0, + partial: 0, + error: 0, + unprocessed: 0, + source_available: 0, + source_unavailable: 0, + started_at_known: 0, + ended_at_known: 0, + usage_known: 0 + }; + const parsedSessions: SessionIndexEntryV1[] = []; + for (let index = 0; index < sessions.length; index += 1) { + const session = parseIndexEntry(sessions[index], `$.sessions[${index}]`); + addUnique(identities, identityKey(session.provider, session.session_id), "session identity"); + calculated[session.processing_state] += 1; + if (session.source_availability === "available") calculated.source_available += 1; + else calculated.source_unavailable += 1; + calculated.started_at_known += 1; + calculated.ended_at_known += 1; + if (session.usage_record_digest !== null) calculated.usage_known += 1; + parsedSessions.push(session); + } + if (!sameIndexCoverage(claimedCoverage, calculated)) throw new Error("index coverage does not reconcile"); + checkedSum("$.coverage processing states", claimedCoverage.complete, claimedCoverage.partial, + claimedCoverage.error, claimedCoverage.unprocessed); + if (claimedCoverage.complete + claimedCoverage.partial + claimedCoverage.error + claimedCoverage.unprocessed !== claimedCoverage.total) { + throw new Error("index processing-state coverage does not reconcile"); + } + checkedSum("$.coverage sources", claimedCoverage.source_available, claimedCoverage.source_unavailable); + if (claimedCoverage.source_available + claimedCoverage.source_unavailable !== claimedCoverage.total) { + throw new Error("index source coverage does not reconcile"); + } + for (let index = 1; index < parsedSessions.length; index += 1) { + if (compareIndexEntries(parsedSessions[index - 1], parsedSessions[index]) > 0) { + throw new Error("sessions are not in canonical order"); + } + } + const result = row as unknown as SessionIndexV1; + if (claimedDigest !== ZERO_DIGEST && canonicalIndexDigest(result) !== claimedDigest) { + throw new Error("session index digest mismatch"); + } + return result; +} + +export function parseSessionSummaryV1(source: string): SessionSummaryV1 { + const row = documentObject(source, "session summary"); + exact(row, "$", [ + "schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "generation_id", + "session_view_digest", "phase_boundaries", "key_operations", "verification_results", "errors", + "unresolved_questions", "rules", "coverage" + ]); + parseInspectionIdentity(row); + parseSummaryBlock(row.phase_boundaries, "$.phase_boundaries"); + parseSummaryBlock(row.key_operations, "$.key_operations"); + parseSummaryBlock(row.verification_results, "$.verification_results"); + parseSummaryErrorBlock(row.errors, "$.errors"); + parseSummaryBlock(row.unresolved_questions, "$.unresolved_questions"); + parseSummaryRules(row.rules, "$.rules"); + parseCoverage(row.coverage, "$.coverage"); + return row as unknown as SessionSummaryV1; +} + +export function parseSessionEventPageV1(source: string): SessionEventPageV1 { + const row = documentObject(source, "session event page"); + exact(row, "$", [ + "schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "generation_id", + "session_view_digest", "total", "range_start", "range_end", "items", "previous_cursor", "next_cursor", + "first_cursor", "last_cursor", "coverage" + ]); + parseInspectionIdentity(row); + const total = integer(row.total, "$.total"); + const rangeStart = integer(row.range_start, "$.range_start"); + const rangeEnd = integer(row.range_end, "$.range_end"); + const items = boundedArray(row.items, "$.items", 100); + if (rangeStart > rangeEnd || rangeEnd > total || items.length !== rangeEnd - rangeStart) { + throw new Error("event page range does not reconcile"); + } + for (const key of ["previous_cursor", "next_cursor", "first_cursor", "last_cursor"] as const) { + nullableText(row[key], `$.${key}`, 4096); + } + if (total === 0 && (rangeStart !== 0 || rangeEnd !== 0 || row.previous_cursor !== null || row.next_cursor !== null || + row.first_cursor !== null || row.last_cursor !== null)) { + throw new Error("empty event page cannot have a range or cursors"); + } + const coverage = parseCoverage(row.coverage, "$.coverage"); + if (total !== coverage.indexed) throw new Error("event page total does not match indexed coverage"); + const parsedItems: SessionEventItemV1[] = []; + for (let index = 0; index < items.length; index += 1) { + parsedItems.push(parseEventItem(items[index], `$.items[${index}]`)); + } + assertCanonicalOrder(parsedItems, compareSummaryEntry, "event items"); + return row as unknown as SessionEventPageV1; +} + +export function parseAgentAnnotationV1(source: string): AgentAnnotationV1 { + const row = documentObject(source, "agent annotation"); + exact(row, "$", ["schema_version", "minimum_reader_version", "project_id", "annotations", "extraction_runs"]); + constant(row.schema_version, 1, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + const projectID = id(row.project_id, "$.project_id"); + const annotations = boundedArray(row.annotations, "$.annotations", 65536); + const runs = boundedArray(row.extraction_runs, "$.extraction_runs", 65536); + const runIDs = new Set(); + for (let index = 0; index < runs.length; index += 1) { + const run = parseExtractionRun(runs[index], `$.extraction_runs[${index}]`, projectID); + addUnique(runIDs, run.run_id, "extraction run"); + } + const annotationIDs = new Set(); + for (let index = 0; index < annotations.length; index += 1) { + const annotation = parseAnnotation(annotations[index], `$.annotations[${index}]`, projectID); + addUnique(annotationIDs, annotation.id, "annotation"); + if (!runIDs.has(annotation.agent_run_id)) { + throw new Error(`annotation "${annotation.id}" references missing extraction run`); + } + } + return row as unknown as AgentAnnotationV1; +} + +export function parseCandidateListV1(source: string): CandidateListV1 { + return parseAgentAnnotationV1(source); +} + +export function parsePricingSnapshotV1(source: string): PricingSnapshotV1 { + return validatePricingSnapshot(documentObject(source, "pricing snapshot"), "$" ); +} + +export function parsePricingSupplementV1(source: string): PricingSupplementV1 { + const row = documentObject(source, "pricing supplement"); + exact(row, "$", [ + "schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "usage_record_digest", + "billing_host", "billed_model_id", "billing_mode", "billing_rule_version", "region", "effective_from", + "effective_until", "rates", "source_url", "detail_url", "audit_reason", "supersedes_snapshot_id" + ]); + constant(row.schema_version, 1, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + id(row.project_id, "$.project_id"); + id(row.provider, "$.provider"); + id(row.session_id, "$.session_id"); + digest(row.usage_record_digest, "$.usage_record_digest"); + text(row.billing_host, "$.billing_host", 4096, true); + text(row.billed_model_id, "$.billed_model_id", 4096, true); + text(row.billing_mode, "$.billing_mode", 4096, true); + id(row.billing_rule_version, "$.billing_rule_version"); + nullableText(row.region, "$.region", 128); + text(row.effective_from, "$.effective_from", 128, true); + nullableText(row.effective_until, "$.effective_until", 128); + parseRates(row.rates, "$.rates"); + httpsURL(row.source_url, "$.source_url"); + nullableURL(row.detail_url, "$.detail_url"); + text(row.audit_reason, "$.audit_reason", 4096, true); + nullableText(row.supersedes_snapshot_id, "$.supersedes_snapshot_id", 256); + return row as unknown as PricingSupplementV1; +} + +export function assertSnapshotBindings(ledger: MachineLedgerV4, index: SessionIndexV1): void { + if (ledger.project_id !== index.project_id || ledger.generation_id !== index.generation_id || + ledger.project_view_digest !== index.project_view_digest || ledger.sync_hashes.session_index_digest !== index.digest) { + throw new Error("ledger and session index snapshot binding mismatch"); + } +} + +function parseTimeline(value: unknown, path: string, generationID: string): TimelineEntryV4 { + const row = object(value, path); + exact(row, path, ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids"]); + const parsedGeneration = id(row.generation_id, `${path}.generation_id`); + if (parsedGeneration !== generationID) throw new Error(`${path}.generation_id does not match presentation`); + id(row.id, `${path}.id`); + text(row.occurred_at, `${path}.occurred_at`, 128); + id(row.kind, `${path}.kind`); + text(row.title, `${path}.title`, 16384); + text(row.summary, `${path}.summary`, 16384); + idArray(row.decision_ids, `${path}.decision_ids`, 256, true); + return row as unknown as TimelineEntryV4; +} + +function parseDecision(value: unknown, path: string): DecisionV4 { + const row = object(value, path); + exact(row, path, [ + "id", "kind", "occurred_at", "title", "rationale", "impact", "status", "reevaluate_when", "supersedes", + "milestone_ids", "session_refs", "provenance", "pinned", "revision" + ]); + id(row.id, `${path}.id`); + oneOf(row.kind, `${path}.kind`, ["decision", "agreement"]); + text(row.occurred_at, `${path}.occurred_at`, 128); + text(row.title, `${path}.title`, 16384); + text(row.rationale, `${path}.rationale`, 16384); + text(row.impact, `${path}.impact`, 16384); + oneOf(row.status, `${path}.status`, ["active", "superseded", "archived"]); + text(row.reevaluate_when, `${path}.reevaluate_when`, 16384); + idArray(row.supersedes, `${path}.supersedes`, 256, true); + idArray(row.milestone_ids, `${path}.milestone_ids`, 256, true); + const refs = boundedArray(row.session_refs, `${path}.session_refs`, 256); + const identities = new Set(); + for (let index = 0; index < refs.length; index += 1) { + const ref = parseSessionReference(refs[index], `${path}.session_refs[${index}]`); + addUnique(identities, identityKey(ref.provider, ref.session_id), "decision session reference"); + } + oneOf(row.provenance, `${path}.provenance`, ["human_created", "migrated", "ai_candidate_confirmed"]); + boolean(row.pinned, `${path}.pinned`); + positiveInteger(row.revision, `${path}.revision`); + return row as unknown as DecisionV4; +} + +function parseSessionReference(value: unknown, path: string): SessionReferenceV4 { + const row = object(value, path); + exact(row, path, ["provider", "session_id"]); + id(row.provider, `${path}.provider`); + id(row.session_id, `${path}.session_id`); + return row as unknown as SessionReferenceV4; +} + +function parseUniqueEntityArray( + value: unknown, + path: string, + maximum: number, + keys: readonly string[], + textKeys: readonly string[] +): void { + const values = boundedArray(value, path, maximum); + const identities = new Set(); + for (let index = 0; index < values.length; index += 1) { + const itemPath = `${path}[${index}]`; + const row = object(values[index], itemPath); + exact(row, itemPath, keys); + const itemID = id(row.id, `${itemPath}.id`); + addUnique(identities, itemID, `${path} identity`); + for (const key of textKeys) text(row[key], `${itemPath}.${key}`, 16384); + } +} + +function parsePatchArray(value: unknown, path: string): HumanPatchV4[] { + const values = boundedArray(value, path, 65536); + return values.map((item, index) => parsePatch(item, `${path}[${index}]`)); +} + +function parsePatch(value: unknown, path: string): HumanPatchV4 { + const row = object(value, path); + exact(row, path, ["entity_id", "field", "operation", "value", "values", "base_generated_hash"], + ["entity_id", "field", "operation", "base_generated_hash"]); + id(row.entity_id, `${path}.entity_id`); + id(row.field, `${path}.field`); + oneOf(row.operation, `${path}.operation`, ["set", "suppress", "restore_default"]); + if (row.value !== undefined) text(row.value, `${path}.value`, 16384); + if (row.values !== undefined) stringArray(row.values, `${path}.values`, 256, 16384); + sha256(row.base_generated_hash, `${path}.base_generated_hash`); + return row as unknown as HumanPatchV4; +} + +function parseBaselineArray(value: unknown, path: string): GeneratedBaselineV4[] { + const values = boundedArray(value, path, 65536); + return values.map((item, index) => parseBaseline(item, `${path}[${index}]`)); +} + +function parseBaseline(value: unknown, path: string): GeneratedBaselineV4 { + const row = object(value, path); + exact(row, path, ["generation_id", "entity_id", "field", "kind", "value", "values", "generated_hash"], + ["generation_id", "entity_id", "field", "kind", "generated_hash"]); + id(row.generation_id, `${path}.generation_id`); + id(row.entity_id, `${path}.entity_id`); + id(row.field, `${path}.field`); + id(row.kind, `${path}.kind`); + if (row.value !== undefined) text(row.value, `${path}.value`, 16384); + if (row.values !== undefined) stringArray(row.values, `${path}.values`, 256, 16384); + sha256(row.generated_hash, `${path}.generated_hash`); + return row as unknown as GeneratedBaselineV4; +} + +function parseLedgerAccounting(value: unknown, path: string): LedgerAccountingV4 { + const row = object(value, path); + exact(row, path, ["total_duration_ms", "total_tokens", "total_cost_usd", "models"]); + integer(row.total_duration_ms, `${path}.total_duration_ms`); + const totalTokens = integer(row.total_tokens, `${path}.total_tokens`); + const totalCost = nullableMoney(row.total_cost_usd, `${path}.total_cost_usd`); + const models = boundedArray(row.models, `${path}.models`, 256); + const modelNames = new Set(); + const parsedModels: LedgerAccountingModelV4[] = []; + let modelTokens = 0; + let modelCost = 0; + let modelCostsComplete = true; + for (let index = 0; index < models.length; index += 1) { + const modelPath = `${path}.models[${index}]`; + const model = object(models[index], modelPath); + exact(model, modelPath, ["model", "total_tokens", "total_cost_usd"]); + const name = text(model.model, `${modelPath}.model`, 16384); + addUnique(modelNames, name, "accounting model"); + const tokens = integer(model.total_tokens, `${modelPath}.total_tokens`); + modelTokens = checkedAdd(modelTokens, tokens, `${path} model token total`); + const cost = nullableMoney(model.total_cost_usd, `${modelPath}.total_cost_usd`); + if (cost === null) modelCostsComplete = false; + else modelCost += cost; + parsedModels.push(model as unknown as LedgerAccountingModelV4); + } + if (models.length > 0 && modelTokens !== totalTokens) throw new Error("accounting token total does not reconcile"); + if (models.length > 0 && !modelCostsComplete && totalCost !== null) { + throw new Error("aggregate price must be null when an included model cost is unknown"); + } + if (models.length > 0 && modelCostsComplete && totalCost !== null && !nearlyEqual(totalCost, modelCost)) { + throw new Error("accounting cost total does not reconcile"); + } + void parsedModels; + return row as unknown as LedgerAccountingV4; +} + +function parseLedgerSession(value: unknown, path: string): LedgerSessionV4 { + const row = object(value, path); + exact(row, path, ["provider", "session_id", "processing_state", "source_availability", "session_view_digest", "usage_record_digest"]); + id(row.provider, `${path}.provider`); + id(row.session_id, `${path}.session_id`); + oneOf(row.processing_state, `${path}.processing_state`, ["complete", "partial", "error", "unprocessed"]); + oneOf(row.source_availability, `${path}.source_availability`, ["available", "unavailable"]); + nullableDigest(row.session_view_digest, `${path}.session_view_digest`); + nullableDigest(row.usage_record_digest, `${path}.usage_record_digest`); + return row as unknown as LedgerSessionV4; +} + +function parseIndexCoverage(value: unknown, path: string): SessionIndexCoverageV1 { + const row = object(value, path); + const keys = [ + "total", "complete", "partial", "error", "unprocessed", "source_available", "source_unavailable", + "started_at_known", "ended_at_known", "usage_known" + ] as const; + exact(row, path, keys); + for (const key of keys) integer(row[key], `${path}.${key}`); + return row as unknown as SessionIndexCoverageV1; +} + +function parseIndexEntry(value: unknown, path: string): SessionIndexEntryV1 { + const row = object(value, path); + exact(row, path, [ + "provider", "session_id", "processing_state", "state_reason_codes", "source_availability", "source_terminal_state", + "started_at", "ended_at", "duration_ms", "warning_count", "record_count", "indexed_event_count", "coverage", + "fact_counts", "session_view_digest", "usage_record_digest", "summary_digest", "last_seen_generation_id", + "last_successful_generation_id" + ]); + id(row.provider, `${path}.provider`); + id(row.session_id, `${path}.session_id`); + oneOf(row.processing_state, `${path}.processing_state`, ["complete", "partial", "error", "unprocessed"]); + const reasons = boundedArray(row.state_reason_codes, `${path}.state_reason_codes`, 64); + const seenReasons = new Set(); + const allowedReasons = [ + "not_discovered", "duplicate_candidate", "freeze_terminal", "malformed_source_records", "unsupported_source_records", + "source_missing", "source_unreadable", "source_ambiguous", "source_unsupported", "source_unavailable", + "partial_observations", "unprojected_facts", "undecodable_facts", "scan_cancelled" + ] as const; + for (let index = 0; index < reasons.length; index += 1) { + const reason = oneOf(reasons[index], `${path}.state_reason_codes[${index}]`, allowedReasons); + addUnique(seenReasons, reason, "state reason"); + } + oneOf(row.source_availability, `${path}.source_availability`, ["available", "unavailable"]); + nullableText(row.source_terminal_state, `${path}.source_terminal_state`, 64); + text(row.started_at, `${path}.started_at`, 128, true); + text(row.ended_at, `${path}.ended_at`, 128, true); + nullableInteger(row.duration_ms, `${path}.duration_ms`); + integer(row.warning_count, `${path}.warning_count`); + nullableInteger(row.record_count, `${path}.record_count`); + const indexedEvents = integer(row.indexed_event_count, `${path}.indexed_event_count`); + const coverage = parseCoverage(row.coverage, `${path}.coverage`); + if (indexedEvents !== coverage.indexed) throw new Error(`${path} indexed event coverage does not reconcile`); + parseFactCounts(row.fact_counts, `${path}.fact_counts`); + nullableDigest(row.session_view_digest, `${path}.session_view_digest`); + nullableDigest(row.usage_record_digest, `${path}.usage_record_digest`); + nullableDigest(row.summary_digest, `${path}.summary_digest`); + nullableText(row.last_seen_generation_id, `${path}.last_seen_generation_id`, 256); + nullableText(row.last_successful_generation_id, `${path}.last_successful_generation_id`, 256); + return row as unknown as SessionIndexEntryV1; +} + +function parseFactCounts(value: unknown, path: string): SessionFactCountsV1 { + const row = object(value, path); + const keys = ["file_change", "command", "verification", "error", "artifact"] as const; + exact(row, path, keys); + for (const key of keys) integer(row[key], `${path}.${key}`); + return row as unknown as SessionFactCountsV1; +} + +function parseInspectionIdentity(row: JsonObject): void { + constant(row.schema_version, 1, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + id(row.project_id, "$.project_id"); + id(row.provider, "$.provider"); + id(row.session_id, "$.session_id"); + id(row.generation_id, "$.generation_id"); + digest(row.session_view_digest, "$.session_view_digest"); +} + +function parseCoverage(value: unknown, path: string): CoverageV1 { + const row = object(value, path); + exact(row, path, COVERAGE_KEYS); + const values = COVERAGE_KEYS.map((key) => integer(row[key], `${path}.${key}`)); + const total = checkedSum(path, ...values.slice(1)); + if (total !== values[0]) throw new Error(`${path} does not reconcile`); + return row as unknown as CoverageV1; +} + +function parseSummaryBlock(value: unknown, path: string): SessionSummaryBlockV1 { + const row = object(value, path); + exact(row, path, ["total", "shown", "omitted", "coverage", "items"]); + const total = integer(row.total, `${path}.total`); + const shown = integer(row.shown, `${path}.shown`); + const omitted = integer(row.omitted, `${path}.omitted`); + const items = boundedArray(row.items, `${path}.items`, 32); + if (shown > total || omitted !== total - shown || items.length !== shown) throw new Error(`${path} does not reconcile`); + parseCoverage(row.coverage, `${path}.coverage`); + const parsed = items.map((item, index) => parseSummaryEntry(item, `${path}.items[${index}]`)); + assertCanonicalOrder(parsed, compareSummaryEntry, `${path} items`); + return row as unknown as SessionSummaryBlockV1; +} + +function parseSummaryErrorBlock(value: unknown, path: string): SessionSummaryErrorBlockV1 { + const row = object(value, path); + exact(row, path, ["total", "shown", "omitted", "coverage", "items"]); + const total = integer(row.total, `${path}.total`); + const shown = integer(row.shown, `${path}.shown`); + const omitted = integer(row.omitted, `${path}.omitted`); + const items = boundedArray(row.items, `${path}.items`, 32); + if (shown > total || omitted !== total - shown || items.length !== shown) throw new Error(`${path} does not reconcile`); + parseCoverage(row.coverage, `${path}.coverage`); + const parsed = items.map((item, index) => parseSummaryErrorEntry(item, `${path}.items[${index}]`)); + assertCanonicalOrder(parsed, compareSummaryEntry, `${path} items`); + return row as unknown as SessionSummaryErrorBlockV1; +} + +function parseSummaryEntry(value: unknown, path: string): SessionSummaryEntryV1 { + const row = object(value, path); + exact(row, path, ["occurred_at", "sequence", "revision_id", "text", "source_revision_ids"]); + text(row.occurred_at, `${path}.occurred_at`, 128); + positiveInteger(row.sequence, `${path}.sequence`); + id(row.revision_id, `${path}.revision_id`); + text(row.text, `${path}.text`, 512); + idArray(row.source_revision_ids, `${path}.source_revision_ids`, 64, true); + return row as unknown as SessionSummaryEntryV1; +} + +function parseSummaryErrorEntry(value: unknown, path: string): SessionSummaryErrorEntryV1 { + const row = object(value, path); + exact(row, path, ["code", "occurred_at", "sequence", "revision_id", "text", "source_revision_ids"]); + id(row.code, `${path}.code`); + text(row.occurred_at, `${path}.occurred_at`, 128); + positiveInteger(row.sequence, `${path}.sequence`); + id(row.revision_id, `${path}.revision_id`); + text(row.text, `${path}.text`, 512); + idArray(row.source_revision_ids, `${path}.source_revision_ids`, 64, true); + return row as unknown as SessionSummaryErrorEntryV1; +} + +function parseSummaryRules(value: unknown, path: string): SessionSummaryRulesV1 { + const row = object(value, path); + exact(row, path, ["rule_id", "rule_version", "dependency_digests"]); + id(row.rule_id, `${path}.rule_id`); + id(row.rule_version, `${path}.rule_version`); + const dependencies = boundedArray(row.dependency_digests, `${path}.dependency_digests`, 128); + const seen = new Set(); + for (let index = 0; index < dependencies.length; index += 1) { + addUnique(seen, digest(dependencies[index], `${path}.dependency_digests[${index}]`), "rule dependency digest"); + } + return row as unknown as SessionSummaryRulesV1; +} + +function parseEventItem(value: unknown, path: string): SessionEventItemV1 { + const row = object(value, path); + exact(row, path, ["kind", "excerpt", "revision_id", "sequence", "occurred_at"]); + oneOf(row.kind, `${path}.kind`, [ + "message", "tool_call", "tool_result", "cwd_change", "usage", "skip", "file_change", "command", + "verification", "error", "artifact" + ]); + text(row.excerpt, `${path}.excerpt`, 512); + id(row.revision_id, `${path}.revision_id`); + positiveInteger(row.sequence, `${path}.sequence`); + text(row.occurred_at, `${path}.occurred_at`, 128); + return row as unknown as SessionEventItemV1; +} + +function parseExtractionRun(value: unknown, path: string, projectID: string): AnnotationExtractionRunV1 { + const row = object(value, path); + exact(row, path, [ + "run_id", "project_id", "status", "extractor_version", "prompt_schema_version", "dependency_digests", + "created_at", "updated_at" + ]); + id(row.run_id, `${path}.run_id`); + if (id(row.project_id, `${path}.project_id`) !== projectID) throw new Error(`${path}.project_id does not match store`); + oneOf(row.status, `${path}.status`, ["pending", "running", "completed", "failed", "cancelled"]); + id(row.extractor_version, `${path}.extractor_version`); + id(row.prompt_schema_version, `${path}.prompt_schema_version`); + const dependencies = boundedArray(row.dependency_digests, `${path}.dependency_digests`, 256); + const seen = new Set(); + for (let index = 0; index < dependencies.length; index += 1) { + addUnique(seen, digest(dependencies[index], `${path}.dependency_digests[${index}]`), "extraction dependency digest"); + } + text(row.created_at, `${path}.created_at`, 128); + text(row.updated_at, `${path}.updated_at`, 128); + return row as unknown as AnnotationExtractionRunV1; +} + +function parseAnnotation(value: unknown, path: string, projectID: string): AgentAnnotationEntryV1 { + const row = object(value, path); + exact(row, path, [ + "id", "project_id", "entity_id", "field", "status", "text", "generation_id", "schema_version", + "analysis_profile", "agent_run_id", "dependencies", "revision", "created_at", "confirmed_decision_id" + ]); + const annotationID = id(row.id, `${path}.id`); + if (id(row.project_id, `${path}.project_id`) !== projectID) throw new Error(`${path}.project_id does not match store`); + id(row.entity_id, `${path}.entity_id`); + id(row.field, `${path}.field`); + const status = oneOf(row.status, `${path}.status`, ["pending", "confirmed", "ignored", "not_decision", "stale"]); + text(row.text, `${path}.text`, 4096); + id(row.generation_id, `${path}.generation_id`); + constant(row.schema_version, 1, `${path}.schema_version`); + id(row.analysis_profile, `${path}.analysis_profile`); + id(row.agent_run_id, `${path}.agent_run_id`); + const dependencies = boundedArray(row.dependencies, `${path}.dependencies`, 256); + const seenDependencies = new Set(); + for (let index = 0; index < dependencies.length; index += 1) { + const dependency = parseAnnotationDependency(dependencies[index], `${path}.dependencies[${index}]`); + addUnique(seenDependencies, `${dependency.kind}\u0000${dependency.revision_id}`, "annotation dependency"); + } + positiveInteger(row.revision, `${path}.revision`); + text(row.created_at, `${path}.created_at`, 128); + const confirmedID = nullableText(row.confirmed_decision_id, `${path}.confirmed_decision_id`, 256); + if (status === "confirmed") { + if (confirmedID === null || !ID.test(confirmedID)) throw new Error(`confirmed candidate "${annotationID}" has no valid decision`); + } else if (confirmedID !== null) { + throw new Error(`candidate "${annotationID}" is not confirmed but has a decision`); + } + return row as unknown as AgentAnnotationEntryV1; +} + +function parseAnnotationDependency(value: unknown, path: string): AnnotationDependencyV1 { + const row = object(value, path); + exact(row, path, ["kind", "revision_id", "digest"]); + oneOf(row.kind, `${path}.kind`, ["observation", "session_view"]); + id(row.revision_id, `${path}.revision_id`); + digest(row.digest, `${path}.digest`); + return row as unknown as AnnotationDependencyV1; +} + +function validatePricingSnapshot(value: unknown, path: string): PricingSnapshotV1 { + const row = object(value, path); + exact(row, path, [ + "schema_version", "minimum_reader_version", "snapshot_id", "project_id", "provider", "session_id", + "usage_record_digest", "billing_host", "billed_model_id", "billing_mode", "billing_rule_version", "region", + "priced_at", "created_at", "status", "modelpricewatch_listing_id", "source_kind", "source_url", "detail_url", + "source_last_updated", "retrieved_at", "promo", "promo_until", "rates", "billable_quantities", "line_costs_usd", + "missing_billing_dimensions", "known_subtotal_usd", "total_cost_usd", "pricing_complete", + "supersedes_snapshot_id", "audit_reason" + ]); + constant(row.schema_version, 1, `${path}.schema_version`); + version(row.minimum_reader_version, `${path}.minimum_reader_version`); + id(row.snapshot_id, `${path}.snapshot_id`); + id(row.project_id, `${path}.project_id`); + id(row.provider, `${path}.provider`); + id(row.session_id, `${path}.session_id`); + digest(row.usage_record_digest, `${path}.usage_record_digest`); + text(row.billing_host, `${path}.billing_host`, 4096, true); + text(row.billed_model_id, `${path}.billed_model_id`, 4096, true); + text(row.billing_mode, `${path}.billing_mode`, 4096, true); + id(row.billing_rule_version, `${path}.billing_rule_version`); + nullableText(row.region, `${path}.region`, 128); + text(row.priced_at, `${path}.priced_at`, 128, true); + text(row.created_at, `${path}.created_at`, 128, true); + const status = oneOf(row.status, `${path}.status`, [ + "pending", "current", "promotion", "stale_estimate", "manual_supplement", "ambiguous", "legacy_unverified", "superseded" + ]); + void status; + const listingID = nullableText(row.modelpricewatch_listing_id, `${path}.modelpricewatch_listing_id`, 256); + const sourceKind = oneOf(row.source_kind, `${path}.source_kind`, ["modelpricewatch", "official", "manual", "unresolved"]); + const sourceURL = nullableURL(row.source_url, `${path}.source_url`); + nullableURL(row.detail_url, `${path}.detail_url`); + nullableText(row.source_last_updated, `${path}.source_last_updated`, 128); + const retrievedAt = nullableText(row.retrieved_at, `${path}.retrieved_at`, 128); + boolean(row.promo, `${path}.promo`); + nullableText(row.promo_until, `${path}.promo_until`, 128); + if (sourceKind === "unresolved") { + if (sourceURL !== null || row.pricing_complete === true) throw new Error("unresolved pricing cannot carry resolved source evidence"); + } else if (sourceURL === null) { + throw new Error("resolved pricing requires HTTPS source evidence"); + } + if (sourceKind === "modelpricewatch" && (listingID === null || listingID === "" || retrievedAt === null)) { + throw new Error("modelpricewatch pricing requires listing and retrieval evidence"); + } + + const rates = parseRates(row.rates, `${path}.rates`); + const quantities = parseQuantities(row.billable_quantities, `${path}.billable_quantities`); + const costs = parseLineCosts(row.line_costs_usd, `${path}.line_costs_usd`); + const missingRows = boundedArray(row.missing_billing_dimensions, `${path}.missing_billing_dimensions`, 32); + const missing = new Set(); + for (let index = 0; index < missingRows.length; index += 1) { + addUnique(missing, text(missingRows[index], `${path}.missing_billing_dimensions[${index}]`, 4096, true), + "missing billing dimension"); + } + const knownSubtotal = money(row.known_subtotal_usd, `${path}.known_subtotal_usd`); + const totalCost = nullableMoney(row.total_cost_usd, `${path}.total_cost_usd`); + const pricingComplete = boolean(row.pricing_complete, `${path}.pricing_complete`); + nullableText(row.supersedes_snapshot_id, `${path}.supersedes_snapshot_id`, 256); + text(row.audit_reason, `${path}.audit_reason`, 4096, true); + + let calculatedSubtotal = 0; + for (const dimension of PRICE_DIMENSIONS) { + const rate = rates[dimension]; + const quantity = quantities[dimension]; + const cost = costs[dimension]; + if (quantity > 0 && (rate === null || cost === null) && !missing.has(dimension)) { + throw new Error(`unknown billed dimension ${dimension} is not reported`); + } + if (rate !== null && cost !== null) { + const expected = quantity * rate / 1_000_000; + if (!nearlyEqual(cost, expected)) throw new Error(`line cost ${dimension} does not match rate and quantity`); + calculatedSubtotal += cost; + } else if ((rate === null) !== (cost === null)) { + throw new Error(`rate and line cost availability disagree for ${dimension}`); + } + } + if (!nearlyEqual(knownSubtotal, calculatedSubtotal)) throw new Error("known subtotal does not equal known line costs"); + if (pricingComplete) { + for (const dimension of PRICE_DIMENSIONS) { + if (rates[dimension] === null || costs[dimension] === null) { + throw new Error("complete pricing contains an unknown amount"); + } + } + if (totalCost === null || missing.size !== 0 || !nearlyEqual(totalCost, knownSubtotal)) { + throw new Error("complete pricing total or missing dimensions do not reconcile"); + } + } else if (totalCost !== null) { + throw new Error("incomplete pricing total must be null"); + } + return row as unknown as PricingSnapshotV1; +} + +function parseRates(value: unknown, path: string): PricingRatesV1 { + const row = object(value, path); + exact(row, path, PRICE_DIMENSIONS); + for (const key of PRICE_DIMENSIONS) nullableMoney(row[key], `${path}.${key}`); + return row as unknown as PricingRatesV1; +} + +function parseQuantities(value: unknown, path: string): BillableQuantitiesV1 { + const row = object(value, path); + exact(row, path, PRICE_DIMENSIONS); + for (const key of PRICE_DIMENSIONS) integer(row[key], `${path}.${key}`); + return row as unknown as BillableQuantitiesV1; +} + +function parseLineCosts(value: unknown, path: string): PricingLineCostsV1 { + const row = object(value, path); + exact(row, path, PRICE_DIMENSIONS); + for (const key of PRICE_DIMENSIONS) nullableMoney(row[key], `${path}.${key}`); + return row as unknown as PricingLineCostsV1; +} + +function documentObject(source: string, kind: string): JsonObject { + assertValidUnicode(source, "JSON source"); + const bytes = Buffer.byteLength(source, "utf8"); + if (bytes > MAX_JSON_BYTES) throw new Error(`${kind} exceeds ${MAX_JSON_BYTES} bytes`); + rejectDuplicateJsonKeys(source); + let value: unknown; + try { + value = JSON.parse(source); + } catch (error) { + throw new Error(`decode ${kind}: ${message(error)}`); + } + assertJsonUnicode(value, "$", new Set()); + return object(value, "$" ); +} + +function object(value: unknown, path: string): JsonObject { + if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + return value as JsonObject; +} + +function exact(value: JsonObject, path: string, allowed: readonly string[], required: readonly string[] = allowed): void { + for (const key of Object.keys(value)) { + if (!allowed.includes(key)) throw new Error(`unknown exact JSON object key "${key}" at ${path}`); + } + for (const key of required) { + if (!Object.prototype.hasOwnProperty.call(value, key)) throw new Error(`missing required JSON object key "${key}" at ${path}`); + } +} + +function boundedArray(value: unknown, path: string, maximum: number): unknown[] { + if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + if (value.length > maximum) throw new Error(`${path} exceeds ${maximum} items`); + return value; +} + +function stringArray(value: unknown, path: string, maximum: number, maximumText: number): string[] { + return boundedArray(value, path, maximum).map((item, index) => text(item, `${path}[${index}]`, maximumText)); +} + +function idArray(value: unknown, path: string, maximum: number, unique = false): string[] { + const values = boundedArray(value, path, maximum); + const seen = new Set(); + return values.map((item, index) => { + const result = id(item, `${path}[${index}]`); + if (unique) addUnique(seen, result, `${path} ID`); + return result; + }); +} + +function text(value: unknown, path: string, maximum: number, nonempty = false): string { + if (typeof value !== "string") throw new Error(`${path} must be a string`); + if (nonempty && value.length === 0) throw new Error(`${path} must not be empty`); + if (Array.from(value).length > maximum) throw new Error(`${path} exceeds ${maximum} characters`); + return value; +} + +function nullableText(value: unknown, path: string, maximum: number): string | null { + if (value === null) return null; + return text(value, path, maximum); +} + +function id(value: unknown, path: string): string { + const result = text(value, path, 256, true); + if (!ID.test(result)) throw new Error(`${path} must be a valid ID`); + return result; +} + +function digest(value: unknown, path: string): string { + if (typeof value !== "string" || !DIGEST.test(value)) throw new Error(`${path} must be a sha256 digest`); + return value; +} + +function nullableDigest(value: unknown, path: string): string | null { + if (value === null) return null; + return digest(value, path); +} + +function sha256(value: unknown, path: string): string { + if (typeof value !== "string" || !SHA256.test(value)) throw new Error(`${path} must be a lowercase SHA-256 value`); + return value; +} + +function integer(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`${path} must be a safe integer`); + if (value < 0) throw new Error(`${path} must be nonnegative`); + return value; +} + +function positiveInteger(value: unknown, path: string): number { + const result = integer(value, path); + if (result < 1) throw new Error(`${path} must be positive`); + return result; +} + +function nullableInteger(value: unknown, path: string): number | null { + if (value === null) return null; + return integer(value, path); +} + +function money(value: unknown, path: string): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + throw new Error(`${path} must be finite and nonnegative`); + } + return value; +} + +function nullableMoney(value: unknown, path: string): number | null { + if (value === null) return null; + return money(value, path); +} + +function boolean(value: unknown, path: string): boolean { + if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`); + return value; +} + +function constant(value: unknown, expected: unknown, path: string): void { + if (value !== expected) throw new Error(`${path} must equal ${String(expected)}`); +} + +function version(value: unknown, path: string): void { + constant(value, "0.4.0", path); +} + +function oneOf(value: unknown, path: string, allowed: readonly T[]): T { + if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} is not in the closed enum`); + return value as T; +} + +function httpsURL(value: unknown, path: string): string { + const result = text(value, path, 2048, true); + if (!result.startsWith("https://") || result.length <= "https://".length || /[\s\p{Cc}]/u.test(result)) { + throw new Error(`${path} must be an HTTPS URL without whitespace or control characters`); + } + return result; +} + +function nullableURL(value: unknown, path: string): string | null { + if (value === null) return null; + return httpsURL(value, path); +} + +function addUnique(seen: Set, value: string, kind: string): void { + if (seen.has(value)) throw new Error(`duplicate ${kind} "${value}"`); + seen.add(value); +} + +function identityKey(provider: string, sessionID: string): string { + return `${provider}\u0000${sessionID}`; +} + +function checkedAdd(left: number, right: number, path: string): number { + if (left > MAX_SAFE - right) throw new Error(`${path} addition overflow`); + return left + right; +} + +function checkedSum(path: string, ...values: number[]): number { + let total = 0; + for (const value of values) total = checkedAdd(total, value, path); + return total; +} + +function sameIndexCoverage(left: SessionIndexCoverageV1, right: SessionIndexCoverageV1): boolean { + return Object.keys(left).every((key) => left[key as keyof SessionIndexCoverageV1] === right[key as keyof SessionIndexCoverageV1]) && + Object.keys(right).length === Object.keys(left).length; +} + +function compareGoStrings(left: string, right: string): number { + return Buffer.compare(Buffer.from(left, "utf8"), Buffer.from(right, "utf8")); +} + +function compareIndexEntries(left: SessionIndexEntryV1, right: SessionIndexEntryV1): number { + const started = compareGoStrings(right.started_at, left.started_at); + if (started !== 0) return started; + const provider = compareGoStrings(left.provider, right.provider); + return provider !== 0 ? provider : compareGoStrings(left.session_id, right.session_id); +} + +function compareSummaryEntry( + left: SessionSummaryEntryV1 | SessionSummaryErrorEntryV1 | SessionEventItemV1, + right: SessionSummaryEntryV1 | SessionSummaryErrorEntryV1 | SessionEventItemV1 +): number { + const occurred = compareGoStrings(left.occurred_at, right.occurred_at); + if (occurred !== 0) return occurred; + if (left.sequence !== right.sequence) return left.sequence - right.sequence; + return compareGoStrings(left.revision_id, right.revision_id); +} + +function assertCanonicalOrder(values: readonly T[], compare: (left: T, right: T) => number, kind: string): void { + for (let index = 1; index < values.length; index += 1) { + if (compare(values[index - 1], values[index]) > 0) throw new Error(`${kind} are not in canonical order`); + } +} + +function decisionCycle(decisions: ReadonlyMap): boolean { + const state = new Map(); + const visit = (decisionID: string): boolean => { + if (state.get(decisionID) === 1) return true; + if (state.get(decisionID) === 2) return false; + state.set(decisionID, 1); + for (const target of decisions.get(decisionID)?.supersedes ?? []) { + if (visit(target)) return true; + } + state.set(decisionID, 2); + return false; + }; + for (const decisionID of decisions.keys()) if (visit(decisionID)) return true; + return false; +} + +function nearlyEqual(left: number, right: number): boolean { + return Math.abs(left - right) <= 1e-12 * Math.max(1, Math.abs(left), Math.abs(right)); +} + +function canonicalIndexDigest(index: SessionIndexV1): string { + const body = { + schema_version: index.schema_version, + minimum_reader_version: index.minimum_reader_version, + project_id: index.project_id, + generation_id: index.generation_id, + project_view_digest: index.project_view_digest, + generated_at: index.generated_at, + sort_version: index.sort_version, + coverage: orderedIndexCoverage(index.coverage), + sessions: index.sessions.map(orderedIndexEntry) + }; + return `sha256:${sha256Text(goJSON(body))}`; +} + +function canonicalLedgerSHA256(ledger: MachineLedgerV4): string { + const body = { + schema_version: ledger.schema_version, + minimum_reader_version: ledger.minimum_reader_version, + minimum_writer_version: ledger.minimum_writer_version, + project_id: ledger.project_id, + generation_id: ledger.generation_id, + project_view_digest: ledger.project_view_digest, + accepted_revision: ledger.accepted_revision, + review_sha256: ledger.review_sha256, + history_sha256: ledger.history_sha256, + accounting: orderedAccounting(ledger.accounting), + sessions: ledger.sessions.map(orderedLedgerSession), + human_patches: ledger.human_patches.map(orderedPatch), + orphan_patches: ledger.orphan_patches.map(orderedPatch), + generated_baselines: ledger.generated_baselines.map(orderedBaseline), + pricing_snapshots: ledger.pricing_snapshots.map(orderedPricingSnapshot), + current_pricing_snapshot_ids: ledger.current_pricing_snapshot_ids, + sync_hashes: { + review_sha256: ledger.sync_hashes.review_sha256, + history_sha256: ledger.sync_hashes.history_sha256, + session_index_digest: ledger.sync_hashes.session_index_digest + } + }; + return sha256Text(goJSON(body)); +} + +function orderedIndexCoverage(value: SessionIndexCoverageV1): SessionIndexCoverageV1 { + return { + total: value.total, + complete: value.complete, + partial: value.partial, + error: value.error, + unprocessed: value.unprocessed, + source_available: value.source_available, + source_unavailable: value.source_unavailable, + started_at_known: value.started_at_known, + ended_at_known: value.ended_at_known, + usage_known: value.usage_known + }; +} + +function orderedCoverage(value: CoverageV1): CoverageV1 { + return { + seen: value.seen, + indexed: value.indexed, + collapsed: value.collapsed, + unprojected: value.unprojected, + undecodable: value.undecodable, + truncated: value.truncated + }; +} + +function orderedFactCounts(value: SessionFactCountsV1): SessionFactCountsV1 { + return { + file_change: value.file_change, + command: value.command, + verification: value.verification, + error: value.error, + artifact: value.artifact + }; +} + +function orderedIndexEntry(value: SessionIndexEntryV1): JsonObject { + return { + provider: value.provider, + session_id: value.session_id, + processing_state: value.processing_state, + state_reason_codes: value.state_reason_codes, + source_availability: value.source_availability, + source_terminal_state: value.source_terminal_state, + started_at: value.started_at, + ended_at: value.ended_at, + duration_ms: value.duration_ms, + warning_count: value.warning_count, + record_count: value.record_count, + indexed_event_count: value.indexed_event_count, + coverage: orderedCoverage(value.coverage), + fact_counts: orderedFactCounts(value.fact_counts), + session_view_digest: value.session_view_digest, + usage_record_digest: value.usage_record_digest, + summary_digest: value.summary_digest, + last_seen_generation_id: value.last_seen_generation_id, + last_successful_generation_id: value.last_successful_generation_id + }; +} + +function orderedAccounting(value: LedgerAccountingV4): JsonObject { + return { + total_duration_ms: value.total_duration_ms, + total_tokens: value.total_tokens, + total_cost_usd: value.total_cost_usd, + models: value.models.map((model) => ({ + model: model.model, + total_tokens: model.total_tokens, + total_cost_usd: model.total_cost_usd + })) + }; +} + +function orderedLedgerSession(value: LedgerSessionV4): JsonObject { + return { + provider: value.provider, + session_id: value.session_id, + processing_state: value.processing_state, + source_availability: value.source_availability, + session_view_digest: value.session_view_digest, + usage_record_digest: value.usage_record_digest + }; +} + +function orderedPatch(value: HumanPatchV4): JsonObject { + return { + entity_id: value.entity_id, + field: value.field, + operation: value.operation, + ...(Object.prototype.hasOwnProperty.call(value, "value") ? { value: value.value } : {}), + ...(Object.prototype.hasOwnProperty.call(value, "values") ? { values: value.values } : {}), + base_generated_hash: value.base_generated_hash + }; +} + +function orderedBaseline(value: GeneratedBaselineV4): JsonObject { + return { + generation_id: value.generation_id, + entity_id: value.entity_id, + field: value.field, + kind: value.kind, + ...(Object.prototype.hasOwnProperty.call(value, "value") ? { value: value.value } : {}), + ...(Object.prototype.hasOwnProperty.call(value, "values") ? { values: value.values } : {}), + generated_hash: value.generated_hash + }; +} + +function orderedPricingSnapshot(value: PricingSnapshotV1): JsonObject { + return { + schema_version: value.schema_version, + minimum_reader_version: value.minimum_reader_version, + snapshot_id: value.snapshot_id, + project_id: value.project_id, + provider: value.provider, + session_id: value.session_id, + usage_record_digest: value.usage_record_digest, + billing_host: value.billing_host, + billed_model_id: value.billed_model_id, + billing_mode: value.billing_mode, + billing_rule_version: value.billing_rule_version, + region: value.region, + priced_at: value.priced_at, + created_at: value.created_at, + status: value.status, + modelpricewatch_listing_id: value.modelpricewatch_listing_id, + source_kind: value.source_kind, + source_url: value.source_url, + detail_url: value.detail_url, + source_last_updated: value.source_last_updated, + retrieved_at: value.retrieved_at, + promo: value.promo, + promo_until: value.promo_until, + rates: orderedPriceDimensions(value.rates), + billable_quantities: orderedPriceDimensions(value.billable_quantities), + line_costs_usd: orderedPriceDimensions(value.line_costs_usd), + missing_billing_dimensions: value.missing_billing_dimensions, + known_subtotal_usd: value.known_subtotal_usd, + total_cost_usd: value.total_cost_usd, + pricing_complete: value.pricing_complete, + supersedes_snapshot_id: value.supersedes_snapshot_id, + audit_reason: value.audit_reason + }; +} + +function orderedPriceDimensions(value: PricingRatesV1 | BillableQuantitiesV1 | PricingLineCostsV1): JsonObject { + return { + input: value.input, + cached_input: value.cached_input, + cache_write_input: value.cache_write_input, + output: value.output, + reasoning_output: value.reasoning_output + }; +} + +function goJSON(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") return goJSONString(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("canonical JSON cannot encode a non-finite number"); + if (Object.is(value, -0)) return "-0"; + return String(value); + } + if (typeof value === "boolean") return value ? "true" : "false"; + if (Array.isArray(value)) return `[${value.map(goJSON).join(",")}]`; + if (typeof value === "object" && value !== null) { + const entries = Object.entries(value as JsonObject).filter(([, child]) => child !== undefined); + return `{${entries.map(([key, child]) => `${goJSONString(key)}:${goJSON(child)}`).join(",")}}`; + } + throw new Error("canonical JSON contains an unsupported value"); +} + +function goJSONString(value: string): string { + return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (character) => { + const code = character.codePointAt(0); + return `\\u${code?.toString(16).padStart(4, "0")}`; + }); +} + +function assertValidUnicode(value: string, path: string): void { + for (let index = 0; index < value.length; index += 1) { + const code = value.charCodeAt(index); + if (code >= 0xd800 && code <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error(`${path} contains an unpaired Unicode surrogate`); + index += 1; + } else if (code >= 0xdc00 && code <= 0xdfff) { + throw new Error(`${path} contains an unpaired Unicode surrogate`); + } + } +} + +function assertJsonUnicode(value: unknown, path: string, seen: Set): void { + if (typeof value === "string") { + assertValidUnicode(value, path); + return; + } + if (typeof value !== "object" || value === null) return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + value.forEach((child, index) => assertJsonUnicode(child, `${path}[${index}]`, seen)); + return; + } + for (const [key, child] of Object.entries(value as JsonObject)) { + assertValidUnicode(key, `${path} key`); + assertJsonUnicode(child, `${path}.${key}`, seen); + } +} + +function rejectDuplicateJsonKeys(source: string): void { + let cursor = 0; + const whitespace = (): void => { + while (source[cursor] === " " || source[cursor] === "\t" || source[cursor] === "\r" || source[cursor] === "\n") cursor += 1; + }; + const parseString = (): string => { + const start = cursor; + cursor += 1; + while (cursor < source.length) { + if (source[cursor] === "\\") { + cursor += 2; + continue; + } + if (source[cursor] === '"') { + cursor += 1; + try { + const decoded = JSON.parse(source.slice(start, cursor)) as unknown; + if (typeof decoded !== "string") throw new Error("not a string"); + assertValidUnicode(decoded, "JSON string"); + return decoded; + } catch (error) { + throw new Error(`decode JSON: malformed string: ${message(error)}`); + } + } + cursor += 1; + } + throw new Error("decode JSON: unterminated string"); + }; + const parseValue = (): void => { + whitespace(); + const token = source[cursor]; + if (token === "{") { + cursor += 1; + whitespace(); + const keys = new Set(); + if (source[cursor] === "}") { + cursor += 1; + return; + } + while (cursor < source.length) { + whitespace(); + if (source[cursor] !== '"') throw new Error("decode JSON: object key must be a string"); + const key = parseString(); + if (keys.has(key)) throw new Error(`duplicate JSON object key "${key}"`); + keys.add(key); + whitespace(); + if (source[cursor] !== ":") throw new Error("decode JSON: missing object colon"); + cursor += 1; + parseValue(); + whitespace(); + if (source[cursor] === "}") { + cursor += 1; + return; + } + if (source[cursor] !== ",") throw new Error("decode JSON: malformed object"); + cursor += 1; + } + throw new Error("decode JSON: unterminated object"); + } + if (token === "[") { + cursor += 1; + whitespace(); + if (source[cursor] === "]") { + cursor += 1; + return; + } + while (cursor < source.length) { + parseValue(); + whitespace(); + if (source[cursor] === "]") { + cursor += 1; + return; + } + if (source[cursor] !== ",") throw new Error("decode JSON: malformed array"); + cursor += 1; + } + throw new Error("decode JSON: unterminated array"); + } + if (token === '"') { + parseString(); + return; + } + const primitive = /^(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(source.slice(cursor)); + if (!primitive) throw new Error("decode JSON: malformed value"); + cursor += primitive[0].length; + }; + parseValue(); + whitespace(); + if (cursor !== source.length) throw new Error("decode JSON: trailing data"); +} + +function message(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts new file mode 100644 index 0000000..b04d99a --- /dev/null +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -0,0 +1,284 @@ +import { readFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + assertSnapshotBindings, + parseAgentAnnotationV1, + parseCandidateListV1, + parseMachineLedgerV4, + parsePricingSnapshotV1, + parsePricingSupplementV1, + parseReviewPresentationV4, + parseSessionEventPageV1, + parseSessionIndexV1, + parseSessionSummaryV1 +} from "../src/data/contracts-v4"; + +const here = dirname(fileURLToPath(import.meta.url)); +const pluginFixture = (name: string): Promise => + readFile(resolve(here, "fixtures/v4", name), "utf8"); +const sharedFixture = (name: string): Promise => + readFile(resolve(here, "../../testdata/contracts/v4", name)); + +type JsonObject = Record; +type Parser = (source: string) => unknown; + +const contracts: ReadonlyArray> = [ + { name: "review-presentation-v4", parser: parseReviewPresentationV4 }, + { name: "machine-ledger-v4", parser: parseMachineLedgerV4 }, + { name: "session-index-v1", parser: parseSessionIndexV1 }, + { name: "session-summary-v1", parser: parseSessionSummaryV1 }, + { name: "session-event-page-v1", parser: parseSessionEventPageV1 }, + { name: "agent-annotation-v1", parser: parseAgentAnnotationV1 }, + { name: "pricing-snapshot-v1", parser: parsePricingSnapshotV1 }, + { name: "pricing-supplement-v1", parser: parsePricingSupplementV1 } +]; + +async function fixtureObject(name: string): Promise { + return JSON.parse(await pluginFixture(name)) as JsonObject; +} + +function clone(value: T): T { + return structuredClone(value); +} + +function decision(id: string, supersedes: string[], status = "active"): JsonObject { + return { + id, + kind: "decision", + occurred_at: "2026-09-04T00:00:00Z", + title: id, + rationale: "reason", + impact: "impact", + status, + reevaluate_when: "later", + supersedes, + milestone_ids: [], + session_refs: [], + provenance: "human_created", + pinned: false, + revision: 1 + }; +} + +describe("frozen v4 contract fixture parity", () => { + for (const contract of contracts) { + it(`accepts the frozen ${contract.name} valid fixture through its production parser`, async () => { + const source = await pluginFixture(`${contract.name}.valid.json`); + expect(() => contract.parser(source)).not.toThrow(); + }); + + it(`rejects the frozen ${contract.name} invalid fixture through its production parser`, async () => { + const source = await pluginFixture(`${contract.name}.invalid.json`); + expect(() => contract.parser(source)).toThrow(); + }); + + it(`keeps both ${contract.name} fixtures byte-identical to the shared Go fixtures`, async () => { + for (const suffix of ["valid", "invalid"] as const) { + const name = `${contract.name}.${suffix}.json`; + await expect(readFile(resolve(here, "fixtures/v4", name))).resolves.toEqual(await sharedFixture(name)); + } + }); + } + + it("exposes CandidateListV1 as the agent-annotation-v1 typed view", async () => { + const source = await pluginFixture("agent-annotation-v1.valid.json"); + expect(parseCandidateListV1(source)).toEqual(parseAgentAnnotationV1(source)); + }); +}); + +describe("strict JSON boundary", () => { + it("rejects duplicate keys at nested object depth", () => { + const source = '{"schema_version":1,"minimum_reader_version":"0.4.0","project_id":"p","annotations":[],"extraction_runs":[{"run_id":"a","run_id":"b"}]}'; + expect(() => parseAgentAnnotationV1(source)).toThrow(/duplicate/i); + }); + + it("rejects case aliases, unknown keys, and missing keys recursively", async () => { + const valid = await fixtureObject("review-presentation-v4.valid.json"); + const alias = clone(valid) as { current_state: JsonObject }; + alias.current_state.Goal = "alias"; + expect(() => parseReviewPresentationV4(JSON.stringify(alias))).toThrow(/unknown|exact/i); + + const unknown = clone(valid) as { current_state: JsonObject }; + unknown.current_state.unknown = true; + expect(() => parseReviewPresentationV4(JSON.stringify(unknown))).toThrow(/unknown|exact/i); + + const missing = clone(valid) as { current_state: JsonObject }; + delete missing.current_state.goal; + expect(() => parseReviewPresentationV4(JSON.stringify(missing))).toThrow(/required|missing/i); + }); + + it("rejects literal and JSON-escaped unpaired surrogates", async () => { + const source = await pluginFixture("agent-annotation-v1.valid.json"); + const literal = source.replace("project-p", "\ud800"); + expect(() => parseAgentAnnotationV1(literal)).toThrow(/surrogate|unicode/i); + const escaped = source.replace("project-p", "\\ud800"); + expect(() => parseAgentAnnotationV1(escaped)).toThrow(/surrogate|unicode/i); + }); + + it("rejects input above the 64 MiB UTF-8 boundary before decoding", () => { + const oversized = `"${"a".repeat((64 << 20) + 1)}"`; + expect(() => parseAgentAnnotationV1(oversized)).toThrow(/67108864|64 MiB|byte/i); + }); + + it("rejects unsafe integers", async () => { + const valid = await fixtureObject("review-presentation-v4.valid.json"); + valid.revision = Number.MAX_SAFE_INTEGER + 1; + expect(() => parseReviewPresentationV4(JSON.stringify(valid))).toThrow(/safe integer/i); + }); +}); + +describe("session contracts", () => { + it("rejects counter addition overflow without relying on a wrapped sum", async () => { + const summary = await fixtureObject("session-summary-v1.valid.json") as { coverage: JsonObject }; + summary.coverage.seen = 0; + summary.coverage.indexed = Number.MAX_SAFE_INTEGER; + summary.coverage.collapsed = 1; + expect(() => parseSessionSummaryV1(JSON.stringify(summary))).toThrow(/overflow|reconcile/i); + }); + + it("uses provider plus session_id for uniqueness", async () => { + const index = await fixtureObject("session-index-v1.valid.json") as { + coverage: JsonObject; + sessions: JsonObject[]; + }; + index.sessions.push({ ...clone(index.sessions[0]), provider: "codex" }); + Object.assign(index.coverage, { + total: 2, + complete: 2, + source_available: 2, + started_at_known: 2, + ended_at_known: 2 + }); + expect(() => parseSessionIndexV1(JSON.stringify(index))).not.toThrow(); + + index.sessions[1] = clone(index.sessions[0]); + expect(() => parseSessionIndexV1(JSON.stringify(index))).toThrow(/duplicate/i); + }); + + it("rejects mixed project, generation, project digest, and index digest bindings", async () => { + const ledger = parseMachineLedgerV4(await pluginFixture("machine-ledger-v4.valid.json")); + const index = parseSessionIndexV1(await pluginFixture("session-index-v1.valid.json")); + ledger.sync_hashes.session_index_digest = index.digest; + expect(() => assertSnapshotBindings(ledger, index)).not.toThrow(); + + for (const field of ["project_id", "generation_id", "project_view_digest"] as const) { + const changed = clone(index); + changed[field] = field === "project_view_digest" ? `sha256:${"9".repeat(64)}` : "other"; + expect(() => assertSnapshotBindings(ledger, changed)).toThrow(/mismatch|binding/i); + } + const wrongDigest = clone(index); + wrongDigest.digest = `sha256:${"9".repeat(64)}`; + expect(() => assertSnapshotBindings(ledger, wrongDigest)).toThrow(/mismatch|binding/i); + }); + + it("rejects cyclic decision supersession graphs", async () => { + const review = await fixtureObject("review-presentation-v4.valid.json") as { decisions: JsonObject[] }; + review.decisions = [decision("a", ["b"]), decision("b", ["a"])]; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/cycle/i); + }); + + it("rejects cursors on a zero-total event page", async () => { + const page = await fixtureObject("session-event-page-v1.valid.json"); + page.previous_cursor = "cursor"; + expect(() => parseSessionEventPageV1(JSON.stringify(page))).toThrow(/empty|cursor/i); + }); + + it("verifies non-zero canonical index digests and ledger self hashes", async () => { + const index = await fixtureObject("session-index-v1.valid.json"); + index.digest = "sha256:473d1dc1e8ebe67d6d14af9793c3272e0e78bc98b8c00c2cff2ba68111dc3565"; + expect(() => parseSessionIndexV1(JSON.stringify(index))).not.toThrow(); + index.digest = `sha256:${"9".repeat(64)}`; + expect(() => parseSessionIndexV1(JSON.stringify(index))).toThrow(/digest/i); + + const ledger = await fixtureObject("machine-ledger-v4.valid.json") as { sync_hashes: JsonObject }; + ledger.sync_hashes.ledger_sha256 = "2649fac1e8df09ee7857f3c337bee613d5f48bad3faa3e1e14bf75ef4651b9b7"; + expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).not.toThrow(); + ledger.sync_hashes.ledger_sha256 = "9".repeat(64); + expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).toThrow(/digest|hash/i); + }); +}); + +describe("pricing and optional-field semantics", () => { + it("distinguishes a complete free price from an unknown price", async () => { + const snapshot = await fixtureObject("pricing-snapshot-v1.valid.json") as { + rates: JsonObject; + line_costs_usd: JsonObject; + missing_billing_dimensions: string[]; + known_subtotal_usd: number; + total_cost_usd: number | null; + pricing_complete: boolean; + }; + for (const key of ["input", "cached_input", "cache_write_input", "output", "reasoning_output"]) { + snapshot.rates[key] = 0; + snapshot.line_costs_usd[key] = 0; + } + snapshot.missing_billing_dimensions = []; + snapshot.known_subtotal_usd = 0; + snapshot.total_cost_usd = 0; + snapshot.pricing_complete = true; + expect(() => parsePricingSnapshotV1(JSON.stringify(snapshot))).not.toThrow(); + + snapshot.rates.input = null; + expect(() => parsePricingSnapshotV1(JSON.stringify(snapshot))).toThrow(/complete|unknown|availability/i); + }); + + it("requires incomplete pricing totals to remain null and report unknown billed dimensions", async () => { + const snapshot = await fixtureObject("pricing-snapshot-v1.valid.json") as { + rates: JsonObject; + line_costs_usd: JsonObject; + missing_billing_dimensions: string[]; + total_cost_usd: number | null; + }; + snapshot.total_cost_usd = 0; + expect(() => parsePricingSnapshotV1(JSON.stringify(snapshot))).toThrow(/incomplete|total/i); + + snapshot.total_cost_usd = null; + snapshot.rates.output = null; + snapshot.line_costs_usd.output = null; + snapshot.missing_billing_dimensions = []; + expect(() => parsePricingSnapshotV1(JSON.stringify(snapshot))).toThrow(/dimension|reported/i); + }); + + it("derives aggregate completeness from current pricing snapshots only", async () => { + const ledger = await fixtureObject("machine-ledger-v4.valid.json") as { + accounting: JsonObject; + pricing_snapshots: JsonObject[]; + current_pricing_snapshot_ids: string[]; + }; + const complete = clone(ledger.pricing_snapshots[0]); + complete.snapshot_id = "snapshot-current"; + complete.pricing_complete = true; + complete.rates = { input: 0, cached_input: 0, cache_write_input: 0, output: 0, reasoning_output: 0 }; + complete.line_costs_usd = { input: 0, cached_input: 0, cache_write_input: 0, output: 0, reasoning_output: 0 }; + complete.billable_quantities = { input: 0, cached_input: 0, cache_write_input: 0, output: 0, reasoning_output: 0 }; + complete.missing_billing_dimensions = []; + complete.known_subtotal_usd = 0; + complete.total_cost_usd = 0; + ledger.pricing_snapshots.push(complete); + ledger.current_pricing_snapshot_ids = ["snapshot-current"]; + ledger.accounting.total_cost_usd = 0; + expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).not.toThrow(); + + ledger.current_pricing_snapshot_ids = ["snapshot-1"]; + expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).toThrow(/aggregate|incomplete|null/i); + }); + + it("preserves explicit empty optional arrays instead of erasing them", async () => { + const review = await fixtureObject("review-presentation-v4.valid.json") as { + human_patches: JsonObject[]; + }; + review.human_patches = [{ + entity_id: "entity-1", + field: "field-1", + operation: "set", + values: [], + base_generated_hash: "1".repeat(64) + }]; + const parsed = parseReviewPresentationV4(JSON.stringify(review)); + expect(parsed.human_patches[0]).toHaveProperty("values"); + expect(parsed.human_patches[0]?.values).toEqual([]); + expect(parsed.human_patches[0]).not.toHaveProperty("value"); + }); +}); diff --git a/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.invalid.json new file mode 100644 index 0000000..47facab --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.invalid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "annotations": [], "extraction_runs": [], "unknown": true +} diff --git a/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json new file mode 100644 index 0000000..9735f12 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "annotations": [], "extraction_runs": [] +} diff --git a/obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.invalid.json b/obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.invalid.json new file mode 100644 index 0000000..c1a510e --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.invalid.json @@ -0,0 +1,2 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "accepted_revision": 0, "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": null, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [{ "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": null, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": null, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "missing_billing_dimensions": ["output"], "known_subtotal_usd": 0, "total_cost_usd": null, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Incomplete" }], "current_pricing_snapshot_ids": ["snapshot-1"], "sync_hashes": { "review_sha256": "bad", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "ledger_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "session_index_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } } diff --git a/obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.valid.json b/obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.valid.json new file mode 100644 index 0000000..d87fe60 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/machine-ledger-v4.valid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "accepted_revision": 0, "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "accounting": { "total_duration_ms": 0, "total_tokens": 0, "total_cost_usd": null, "models": [] }, "sessions": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [], "pricing_snapshots": [{ "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": 1.0, "cached_input": 0, "cache_write_input": null, "output": 2.0, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0.00001, "cached_input": 0, "cache_write_input": null, "output": 0.00001, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0.00002, "total_cost_usd": null, "pricing_complete": false, "supersedes_snapshot_id": null, "audit_reason": "Official price matched exact billing route." }], "current_pricing_snapshot_ids": ["snapshot-1"], + "sync_hashes": { "review_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "history_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "ledger_sha256": "0000000000000000000000000000000000000000000000000000000000000000", "session_index_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222" } +} diff --git a/obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.invalid.json new file mode 100644 index 0000000..080aed7 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.invalid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "not-a-price-state", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0, "cached_input": null, "cache_write_input": null, "output": null, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0, "total_cost_usd": null, "pricing_complete": true, "supersedes_snapshot_id": null, "audit_reason": "Incomplete" +} diff --git a/obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.valid.json new file mode 100644 index 0000000..bcbad38 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/pricing-snapshot-v1.valid.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "snapshot_id": "snapshot-1", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "priced_at": "2026-09-04T00:00:00Z", "created_at": "2026-09-04T00:00:00Z", "status": "current", "modelpricewatch_listing_id": null, "source_kind": "official", "source_url": "https://example.test/pricing", "detail_url": null, "source_last_updated": null, "retrieved_at": null, "promo": false, "promo_until": null, + "rates": { "input": 1.0, "cached_input": 0, "cache_write_input": null, "output": 2.0, "reasoning_output": null }, "billable_quantities": { "input": 10, "cached_input": 0, "cache_write_input": 0, "output": 5, "reasoning_output": 0 }, "line_costs_usd": { "input": 0.00001, "cached_input": 0, "cache_write_input": null, "output": 0.00001, "reasoning_output": null }, "missing_billing_dimensions": [], "known_subtotal_usd": 0.00002, "total_cost_usd": null, "pricing_complete": false, "supersedes_snapshot_id": null, "audit_reason": "Official price matched exact billing route." +} diff --git a/obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.invalid.json new file mode 100644 index 0000000..3cfad38 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.invalid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "not-a-url", "detail_url": null, "audit_reason": "Invalid source.", "supersedes_snapshot_id": null +} diff --git a/obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.valid.json new file mode 100644 index 0000000..042ffbf --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/pricing-supplement-v1.valid.json @@ -0,0 +1,3 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "codex", "session_id": "session-1", "usage_record_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "billing_host": "api.example.test", "billed_model_id": "model-1", "billing_mode": "standard", "billing_rule_version": "rules-v1", "region": null, "effective_from": "2026-09-01T00:00:00Z", "effective_until": null, "rates": { "input": 0, "cached_input": null, "cache_write_input": null, "output": 0, "reasoning_output": null }, "source_url": "https://example.test/pricing", "detail_url": null, "audit_reason": "Public pricing page confirms free route.", "supersedes_snapshot_id": null +} diff --git a/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json new file mode 100644 index 0000000..b5fad62 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, + "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04", "unknown": true }, + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] +} diff --git a/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json new file mode 100644 index 0000000..feb0a10 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, + "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04" }, + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] +} diff --git a/obsidian-plugin/tests/fixtures/v4/session-event-page-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/session-event-page-v1.invalid.json new file mode 100644 index 0000000..d0fc5d1 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/session-event-page-v1.invalid.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "claude", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "total": 0, "range_start": 0, "range_end": 0, "items": [], "previous_cursor": "cursor", "next_cursor": null, "first_cursor": null, "last_cursor": null, + "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 } +} diff --git a/obsidian-plugin/tests/fixtures/v4/session-event-page-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/session-event-page-v1.valid.json new file mode 100644 index 0000000..7c86352 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/session-event-page-v1.valid.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "claude", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "total": 0, "range_start": 0, "range_end": 0, "items": [], "previous_cursor": null, "next_cursor": null, "first_cursor": null, "last_cursor": null, + "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 } +} diff --git a/obsidian-plugin/tests/fixtures/v4/session-index-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/session-index-v1.invalid.json new file mode 100644 index 0000000..0854952 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/session-index-v1.invalid.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, + "minimum_reader_version": "0.4.0", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "project_id": "project-p", + "generation_id": "generation-1", + "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "generated_at": "2026-09-04T00:00:00Z", + "sort_version": "started-at-desc-null-last-provider-session-v1", + "coverage": { "total": 2, "complete": 1, "partial": 0, "error": 0, "unprocessed": 0, "source_available": 1, "source_unavailable": 0, "started_at_known": 1, "ended_at_known": 1, "usage_known": 0 }, + "sessions": [] +} diff --git a/obsidian-plugin/tests/fixtures/v4/session-index-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/session-index-v1.valid.json new file mode 100644 index 0000000..840b615 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/session-index-v1.valid.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, + "minimum_reader_version": "0.4.0", + "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "project_id": "project-p", + "generation_id": "generation-1", + "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "generated_at": "2026-09-04T00:00:00Z", + "sort_version": "started-at-desc-null-last-provider-session-v1", + "coverage": { "total": 1, "complete": 1, "partial": 0, "error": 0, "unprocessed": 0, "source_available": 1, "source_unavailable": 0, "started_at_known": 1, "ended_at_known": 1, "usage_known": 0 }, + "sessions": [{ + "provider": "claude", + "session_id": "session-1", + "processing_state": "complete", + "state_reason_codes": [], + "source_availability": "available", + "source_terminal_state": null, + "started_at": "2026-09-04T00:00:00Z", + "ended_at": "2026-09-04T00:01:00Z", + "duration_ms": 60000, + "warning_count": 0, + "record_count": 1, + "indexed_event_count": 1, + "coverage": { "seen": 1, "indexed": 1, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, + "fact_counts": { "file_change": 0, "command": 0, "verification": 0, "error": 0, "artifact": 0 }, + "session_view_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "usage_record_digest": null, + "summary_digest": null, + "last_seen_generation_id": "generation-1", + "last_successful_generation_id": "generation-1" + }] +} diff --git a/obsidian-plugin/tests/fixtures/v4/session-summary-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/session-summary-v1.invalid.json new file mode 100644 index 0000000..a5d2b6b --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/session-summary-v1.invalid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "opencode", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "phase_boundaries": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "key_operations": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "verification_results": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "errors": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "unresolved_questions": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, + "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "unknown": true +} diff --git a/obsidian-plugin/tests/fixtures/v4/session-summary-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/session-summary-v1.valid.json new file mode 100644 index 0000000..9ceab95 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/session-summary-v1.valid.json @@ -0,0 +1,5 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "provider": "opencode", "session_id": "session-1", "generation_id": "generation-1", "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "phase_boundaries": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "key_operations": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "verification_results": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "errors": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, "unresolved_questions": { "total": 0, "shown": 0, "omitted": 0, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "items": [] }, + "rules": { "rule_id": "summary-rules", "rule_version": "v1", "dependency_digests": [] }, "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 } +} From fbca30c0cbbb7cfe3934ac08c5e99209b0802b84 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 15:50:52 +0800 Subject: [PATCH 08/25] fix: close v4 contract review gaps --- obsidian-plugin/src/data/contracts-v4.ts | 4 +- obsidian-plugin/tests/contracts-v4.test.ts | 54 ++++++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index 02009a1..dc6cafe 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -360,6 +360,8 @@ export function parsePricingSupplementV1(source: string): PricingSupplementV1 { } export function assertSnapshotBindings(ledger: MachineLedgerV4, index: SessionIndexV1): void { + if (index.digest === ZERO_DIGEST) throw new Error("session index digest is unset"); + if (ledger.sync_hashes.ledger_sha256 === ZERO_SHA256) throw new Error("machine ledger self hash is unset"); if (ledger.project_id !== index.project_id || ledger.generation_id !== index.generation_id || ledger.project_view_digest !== index.project_view_digest || ledger.sync_hashes.session_index_digest !== index.digest) { throw new Error("ledger and session index snapshot binding mismatch"); @@ -905,7 +907,7 @@ function idArray(value: unknown, path: string, maximum: number, unique = false): function text(value: unknown, path: string, maximum: number, nonempty = false): string { if (typeof value !== "string") throw new Error(`${path} must be a string`); if (nonempty && value.length === 0) throw new Error(`${path} must not be empty`); - if (Array.from(value).length > maximum) throw new Error(`${path} exceeds ${maximum} characters`); + if (Buffer.byteLength(value, "utf8") > maximum) throw new Error(`${path} exceeds ${maximum} UTF-8 bytes`); return value; } diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index b04d99a..ece4642 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -62,6 +62,19 @@ function decision(id: string, supersedes: string[], status = "active"): JsonObje }; } +async function nonzeroBoundSnapshots(): Promise<{ + ledger: ReturnType; + index: ReturnType; +}> { + const indexRaw = await fixtureObject("session-index-v1.valid.json"); + indexRaw.digest = "sha256:473d1dc1e8ebe67d6d14af9793c3272e0e78bc98b8c00c2cff2ba68111dc3565"; + const index = parseSessionIndexV1(JSON.stringify(indexRaw)); + const ledgerRaw = await fixtureObject("machine-ledger-v4.valid.json") as { sync_hashes: JsonObject }; + ledgerRaw.sync_hashes.session_index_digest = index.digest; + ledgerRaw.sync_hashes.ledger_sha256 = "5330d4167966e653a320cb3e3582ad7c82fe568639f0b358d67124548d27f5b7"; + return { ledger: parseMachineLedgerV4(JSON.stringify(ledgerRaw)), index }; +} + describe("frozen v4 contract fixture parity", () => { for (const contract of contracts) { it(`accepts the frozen ${contract.name} valid fixture through its production parser`, async () => { @@ -127,6 +140,30 @@ describe("strict JSON boundary", () => { valid.revision = Number.MAX_SAFE_INTEGER + 1; expect(() => parseReviewPresentationV4(JSON.stringify(valid))).toThrow(/safe integer/i); }); + + it("applies string ceilings to UTF-8 bytes for CJK and emoji", async () => { + const review = await fixtureObject("review-presentation-v4.valid.json") as { current_state: JsonObject }; + review.current_state.goal = `${"界".repeat(5461)}a`; + expect(Buffer.byteLength(review.current_state.goal as string, "utf8")).toBe(16384); + expect(() => parseReviewPresentationV4(JSON.stringify(review))).not.toThrow(); + review.current_state.goal = `${review.current_state.goal as string}界`; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/16384|byte/i); + + review.current_state.goal = "🙂".repeat(4096); + expect(Buffer.byteLength(review.current_state.goal as string, "utf8")).toBe(16384); + expect(() => parseReviewPresentationV4(JSON.stringify(review))).not.toThrow(); + review.current_state.goal = `${review.current_state.goal as string}🙂`; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/16384|byte/i); + }); + + it("applies pricing text ceilings to UTF-8 bytes", async () => { + const snapshot = await fixtureObject("pricing-snapshot-v1.valid.json"); + snapshot.audit_reason = `${"价".repeat(1365)}a`; + expect(Buffer.byteLength(snapshot.audit_reason as string, "utf8")).toBe(4096); + expect(() => parsePricingSnapshotV1(JSON.stringify(snapshot))).not.toThrow(); + snapshot.audit_reason = `${snapshot.audit_reason as string}价`; + expect(() => parsePricingSnapshotV1(JSON.stringify(snapshot))).toThrow(/4096|byte/i); + }); }); describe("session contracts", () => { @@ -158,9 +195,7 @@ describe("session contracts", () => { }); it("rejects mixed project, generation, project digest, and index digest bindings", async () => { - const ledger = parseMachineLedgerV4(await pluginFixture("machine-ledger-v4.valid.json")); - const index = parseSessionIndexV1(await pluginFixture("session-index-v1.valid.json")); - ledger.sync_hashes.session_index_digest = index.digest; + const { ledger, index } = await nonzeroBoundSnapshots(); expect(() => assertSnapshotBindings(ledger, index)).not.toThrow(); for (const field of ["project_id", "generation_id", "project_view_digest"] as const) { @@ -173,6 +208,19 @@ describe("session contracts", () => { expect(() => assertSnapshotBindings(ledger, wrongDigest)).toThrow(/mismatch|binding/i); }); + it("rejects placeholder digests at the accepted snapshot binding boundary", async () => { + const zeroLedger = parseMachineLedgerV4(await pluginFixture("machine-ledger-v4.valid.json")); + const zeroIndex = parseSessionIndexV1(await pluginFixture("session-index-v1.valid.json")); + zeroLedger.sync_hashes.session_index_digest = zeroIndex.digest; + expect(() => assertSnapshotBindings(zeroLedger, zeroIndex)).toThrow(/unset|zero|placeholder|digest/i); + + const { ledger, index } = await nonzeroBoundSnapshots(); + const zeroSelfHash = clone(ledger); + zeroSelfHash.sync_hashes.ledger_sha256 = "0".repeat(64); + expect(() => assertSnapshotBindings(zeroSelfHash, index)).toThrow(/unset|zero|placeholder|hash/i); + expect(() => assertSnapshotBindings(ledger, index)).not.toThrow(); + }); + it("rejects cyclic decision supersession graphs", async () => { const review = await fixtureObject("review-presentation-v4.valid.json") as { decisions: JsonObject[] }; review.decisions = [decision("a", ["b"]), decision("b", ["a"])]; From 114d857563d1424795ade1915e0f595e861ccac5 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 17:25:23 +0800 Subject: [PATCH 09/25] feat: prove v4 compatibility matrix --- internal/cli/diagnostic.go | 7 + internal/cli/sync.go | 82 +++++ internal/cli/sync_test.go | 78 +++++ internal/memory/publication_proof_test.go | 22 ++ internal/memory/types.go | 18 +- internal/memorystore/store.go | 9 + internal/memorystore/store_test.go | 47 +++ internal/migrationv3/plan_test.go | 19 + internal/migrationv4/migrate.go | 277 +++++++++++++++ internal/migrationv4/migrate_test.go | 402 ++++++++++++++++++++++ internal/migrationv4/plan.go | 141 ++++++++ internal/migrationv4/types.go | 68 ++++ internal/publication/service.go | 250 ++++++++++---- internal/publication/service_test.go | 221 ++++++++++++ internal/reviewv2/v3_test.go | 26 ++ internal/syncproject/migration.go | 330 ++++++++++++++++++ internal/syncproject/service.go | 14 +- internal/syncproject/service_test.go | 262 ++++++++++++++ testdata/contracts/migration/mixed.json | 8 + testdata/contracts/migration/newer.json | 5 + testdata/contracts/migration/partial.json | 6 + testdata/contracts/migration/v2.json | 6 + testdata/contracts/migration/v3.json | 7 + testdata/contracts/migration/v4.json | 7 + 24 files changed, 2241 insertions(+), 71 deletions(-) create mode 100644 internal/memory/publication_proof_test.go create mode 100644 internal/migrationv4/migrate.go create mode 100644 internal/migrationv4/migrate_test.go create mode 100644 internal/migrationv4/plan.go create mode 100644 internal/migrationv4/types.go create mode 100644 internal/syncproject/migration.go create mode 100644 testdata/contracts/migration/mixed.json create mode 100644 testdata/contracts/migration/newer.json create mode 100644 testdata/contracts/migration/partial.json create mode 100644 testdata/contracts/migration/v2.json create mode 100644 testdata/contracts/migration/v3.json create mode 100644 testdata/contracts/migration/v4.json diff --git a/internal/cli/diagnostic.go b/internal/cli/diagnostic.go index 26831b9..0bb6eaf 100644 --- a/internal/cli/diagnostic.go +++ b/internal/cli/diagnostic.go @@ -11,6 +11,7 @@ import ( "github.com/neomei/SessionReviewer/internal/project" "github.com/neomei/SessionReviewer/internal/reviewv2" syncengine "github.com/neomei/SessionReviewer/internal/sync" + "github.com/neomei/SessionReviewer/internal/syncproject" ) type Diagnostic struct { @@ -22,6 +23,12 @@ type Diagnostic struct { func writeDiagnostic(w io.Writer, action string, err error) int { diagnostic := fallbackDiagnostic(action) switch { + case errors.Is(err, syncproject.ErrMigrationRequired) && action == "sync": + diagnostic = Diagnostic{ + Code: "migration_required", + Message: "explicit v3 to v4 migration is required", + Hint: "run session-reviewer sync --dry-run --json, then confirm with the returned preview digest", + } case errors.As(err, new(*reviewv2.ErrMigrationRequired)) && action == "apply": diagnostic = Diagnostic{ Code: "E_APPLY_MIGRATION_REQUIRED", diff --git a/internal/cli/sync.go b/internal/cli/sync.go index 3dc11af..d0d1dec 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -3,6 +3,7 @@ package cli import ( "context" "encoding/json" + "errors" "flag" "fmt" "io" @@ -14,16 +15,20 @@ import ( "github.com/neomei/SessionReviewer/internal/config" "github.com/neomei/SessionReviewer/internal/platform" + "github.com/neomei/SessionReviewer/internal/publication" syncengine "github.com/neomei/SessionReviewer/internal/sync" "github.com/neomei/SessionReviewer/internal/syncproject" ) var syncProject = syncproject.Run +var syncMigrationProject = defaultSyncMigrationProject const syncHelp = `Synchronize editable Session Review Markdown with the configured Obsidian vault. Usage: session-reviewer sync [--dry-run] [--cwd PROJECT | --project-id ID] [--data-dir DATA] + session-reviewer sync --dry-run [--project-id ID] [--data-dir DATA] --json + session-reviewer sync --confirm-migration --expected-preview-digest SHA256 [--project-id ID] [--data-dir DATA] --json session-reviewer sync status [--json] [--cwd PROJECT | --project-id ID] [--data-dir DATA] session-reviewer sync resolve --conflict ID --action accept_project|accept_obsidian [--cwd PROJECT | --project-id ID] [--data-dir DATA] session-reviewer sync resolve --conflict ID --action manual_merge --file PATH [--cwd PROJECT | --project-id ID] [--data-dir DATA] @@ -47,6 +52,9 @@ func runSync(args []string, stdout, stderr io.Writer) int { fmt.Fprint(stdout, syncHelp) return 0 } + if explicitMigrationArgs(args) { + return runSyncMigration(args, stdout, stderr) + } mode := "sync" if len(args) > 0 && (args[0] == "status" || args[0] == "resolve" || args[0] == "repair-machine-ledger") { mode = args[0] @@ -179,6 +187,80 @@ func runSync(args []string, stdout, stderr io.Writer) int { } } +func explicitMigrationArgs(args []string) bool { + hasDryRun, hasJSON := false, false + for _, arg := range args { + switch arg { + case "--confirm-migration": + return true + case "--dry-run": + hasDryRun = true + case "--json": + hasJSON = true + } + } + return hasDryRun && hasJSON +} + +func runSyncMigration(args []string, stdout, stderr io.Writer) int { + request, err := ParseSyncMigrationContract(args) + if err != nil { + return writeSyncMigrationError(stderr, err) + } + dataDir, err := resolveSyncDataDir(request.DataDir) + if err != nil { + return writeSyncMigrationError(stderr, err) + } + mode := syncproject.MigrationDryRun + if request.Mode == "confirm-migration" { + mode = syncproject.MigrationConfirm + } + result, err := syncMigrationProject(context.Background(), syncproject.MigrationOptions{ + Options: syncproject.Options{ + ProjectID: request.ProjectID, DataDir: dataDir, GOOS: runtime.GOOS, + Now: time.Now, Trigger: syncengine.TriggerCLI, + }, + Mode: mode, ExpectedPreviewDigest: request.ExpectedPreviewDigest, + }) + if err != nil { + return writeSyncMigrationError(stderr, err) + } + encoder := json.NewEncoder(stdout) + encoder.SetEscapeHTML(false) + if err := encoder.Encode(result); err != nil { + return writeSyncMigrationError(stderr, err) + } + return 0 +} + +func defaultSyncMigrationProject(ctx context.Context, options syncproject.MigrationOptions) (syncproject.MigrationResult, error) { + options.Publish = func(ctx context.Context, plan syncproject.MigrationPublication) error { + _, err := publication.Publish(ctx, publication.Options{ + ProjectID: plan.ProjectID, PreparedGeneration: plan.PreparedGeneration, + Plan: plan.Plan, Mapping: plan.Mapping, DataRoot: plan.DataRoot, + Now: options.Now, + }) + return err + } + return syncproject.RunMigration(ctx, options) +} + +func writeSyncMigrationError(output io.Writer, err error) int { + code := "migration_failed" + message := "migration failed" + var contract ContractError + switch { + case errors.As(err, &contract): + code, message = contract.Code, contract.Message + case errors.Is(err, syncproject.ErrMigrationPreviewStale): + code, message = ContractCodeMigrationPreviewStale, "migration preview changed" + case errors.Is(err, syncproject.ErrMigrationRequired): + code, message = "migration_required", "explicit v3 to v4 migration is required" + } + _ = json.NewEncoder(output).Encode(map[string]string{"code": code, "message": message}) + return 1 +} + func resolveSyncDataDir(dataDir string) (string, error) { if dataDir == "" { resolved, err := platform.DataDir(currentEnv()) diff --git a/internal/cli/sync_test.go b/internal/cli/sync_test.go index a6c4de8..9291e6f 100644 --- a/internal/cli/sync_test.go +++ b/internal/cli/sync_test.go @@ -14,6 +14,7 @@ import ( "github.com/neomei/SessionReviewer/internal/config" "github.com/neomei/SessionReviewer/internal/ledger" + "github.com/neomei/SessionReviewer/internal/migrationv4" "github.com/neomei/SessionReviewer/internal/platform" "github.com/neomei/SessionReviewer/internal/reviewv2" syncengine "github.com/neomei/SessionReviewer/internal/sync" @@ -61,6 +62,83 @@ func TestSyncProjectServiceCLIDelegationPreservesFormatting(t *testing.T) { } } +func TestRunSyncMigrationModesUseInjectableServiceAndJSON(t *testing.T) { + originalMigration := syncMigrationProject + originalSync := syncProject + t.Cleanup(func() { syncMigrationProject, syncProject = originalMigration, originalSync }) + syncProject = func(context.Context, syncproject.Options) (syncengine.Report, error) { + t.Fatal("explicit migration mode reached ordinary sync") + return syncengine.Report{}, nil + } + dataRoot := t.TempDir() + digest := "sha256:" + strings.Repeat("a", 64) + calls := 0 + syncMigrationProject = func(ctx context.Context, options syncproject.MigrationOptions) (syncproject.MigrationResult, error) { + calls++ + if ctx == nil || options.ProjectID != "project-p" || options.DataDir != dataRoot || options.GOOS != runtime.GOOS || options.Now == nil || options.Trigger != syncengine.TriggerCLI { + t.Fatalf("migration options = %+v", options.Options) + } + if calls == 1 && (options.Mode != syncproject.MigrationDryRun || options.ExpectedPreviewDigest != "") { + t.Fatalf("dry-run options = %+v", options) + } + if calls == 2 && (options.Mode != syncproject.MigrationConfirm || options.ExpectedPreviewDigest != digest) { + t.Fatalf("confirm options = %+v", options) + } + return syncproject.MigrationResult{Preview: migrationv4.MigrationPreview{SchemaVersion: 1, PreviewDigest: digest}, Applied: calls == 2}, nil + } + for _, args := range [][]string{ + {"sync", "--dry-run", "--project-id", "project-p", "--data-dir", dataRoot, "--json"}, + {"sync", "--confirm-migration", "--expected-preview-digest", digest, "--project-id", "project-p", "--data-dir", dataRoot, "--json"}, + } { + var out, errOut bytes.Buffer + if code := Run(args, &out, &errOut); code != 0 || errOut.Len() != 0 { + t.Fatalf("args=%v code=%d stdout=%q stderr=%q", args, code, out.String(), errOut.String()) + } + var result syncproject.MigrationResult + if err := json.Unmarshal(out.Bytes(), &result); err != nil || result.Preview.PreviewDigest != digest { + t.Fatalf("args=%v result=%+v err=%v", args, result, err) + } + } +} + +func TestRunSyncMigrationStaleIsOneStableJSONObject(t *testing.T) { + original := syncMigrationProject + t.Cleanup(func() { syncMigrationProject = original }) + syncMigrationProject = func(context.Context, syncproject.MigrationOptions) (syncproject.MigrationResult, error) { + return syncproject.MigrationResult{}, syncproject.ErrMigrationPreviewStale + } + digest := "sha256:" + strings.Repeat("a", 64) + var out, errOut bytes.Buffer + code := Run([]string{"sync", "--confirm-migration", "--expected-preview-digest", digest, "--data-dir", t.TempDir(), "--json"}, &out, &errOut) + if code != 1 || out.Len() != 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + var diagnostic map[string]string + if err := json.Unmarshal(errOut.Bytes(), &diagnostic); err != nil || diagnostic["code"] != ContractCodeMigrationPreviewStale { + t.Fatalf("diagnostic=%v err=%v raw=%q", diagnostic, err, errOut.String()) + } +} + +func TestRunSyncPlainV3ReturnsMigrationRequiredWithoutDispatchingMigration(t *testing.T) { + originalSync := syncProject + originalMigration := syncMigrationProject + t.Cleanup(func() { syncProject, syncMigrationProject = originalSync, originalMigration }) + calls := 0 + syncProject = func(context.Context, syncproject.Options) (syncengine.Report, error) { + calls++ + return syncengine.Report{}, syncproject.ErrMigrationRequired + } + syncMigrationProject = func(context.Context, syncproject.MigrationOptions) (syncproject.MigrationResult, error) { + t.Fatal("plain sync implicitly dispatched migration") + return syncproject.MigrationResult{}, nil + } + var out, errOut bytes.Buffer + code := Run([]string{"sync", "--data-dir", t.TempDir()}, &out, &errOut) + if code != 1 || calls != 1 || out.Len() != 0 || !strings.Contains(errOut.String(), "migration_required") { + t.Fatalf("code=%d calls=%d stdout=%q stderr=%q", code, calls, out.String(), errOut.String()) + } +} + // Mapping and CWD authentication belong to the extracted service. Resolving // CWD in the CLI first would change legacy error ordering and bypass the seam. func TestSyncProjectServiceCLILeavesCWDResolutionToService(t *testing.T) { diff --git a/internal/memory/publication_proof_test.go b/internal/memory/publication_proof_test.go new file mode 100644 index 0000000..ac26453 --- /dev/null +++ b/internal/memory/publication_proof_test.go @@ -0,0 +1,22 @@ +package memory + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestPublicationProofLegacyJSONBytesRemainUnchanged(t *testing.T) { + proof := PublicationProof{ + ProjectID: "project-p", GenerationID: "generation-1", ManifestDigest: "sha256:" + strings.Repeat("1", 64), ProjectViewDigest: "sha256:" + strings.Repeat("2", 64), + ReviewSHA256: strings.Repeat("3", 64), HistorySHA256: strings.Repeat("4", 64), LedgerSHA256: strings.Repeat("5", 64), JournalVerified: true, + } + body, err := json.Marshal(proof) + if err != nil { + t.Fatal(err) + } + want := `{"project_id":"project-p","generation_id":"generation-1","manifest_digest":"sha256:` + strings.Repeat("1", 64) + `","project_view_digest":"sha256:` + strings.Repeat("2", 64) + `","review_sha256":"` + strings.Repeat("3", 64) + `","history_sha256":"` + strings.Repeat("4", 64) + `","ledger_sha256":"` + strings.Repeat("5", 64) + `","journal_verified":true}` + if string(body) != want { + t.Fatalf("legacy proof bytes changed:\n got %s\nwant %s", body, want) + } +} diff --git a/internal/memory/types.go b/internal/memory/types.go index 4cca09b..cf7b57a 100644 --- a/internal/memory/types.go +++ b/internal/memory/types.go @@ -1331,12 +1331,14 @@ func validTerminalAvailability(state TerminalState, availability SourceAvailabil // PublicationProof verifies that all public projections match before committing. type PublicationProof struct { - ProjectID string `json:"project_id"` - GenerationID string `json:"generation_id"` - ManifestDigest string `json:"manifest_digest"` - ProjectViewDigest string `json:"project_view_digest"` - ReviewSHA256 string `json:"review_sha256"` - HistorySHA256 string `json:"history_sha256"` - LedgerSHA256 string `json:"ledger_sha256"` - JournalVerified bool `json:"journal_verified"` + ProjectID string `json:"project_id"` + GenerationID string `json:"generation_id"` + ManifestDigest string `json:"manifest_digest"` + ProjectViewDigest string `json:"project_view_digest"` + ReviewSHA256 string `json:"review_sha256"` + HistorySHA256 string `json:"history_sha256"` + LedgerSHA256 string `json:"ledger_sha256"` + JournalVerified bool `json:"journal_verified"` + Version int `json:"version,omitempty"` + SessionIndexSHA256 string `json:"session_index_sha256,omitempty"` } diff --git a/internal/memorystore/store.go b/internal/memorystore/store.go index f351885..b824f8d 100644 --- a/internal/memorystore/store.go +++ b/internal/memorystore/store.go @@ -537,11 +537,20 @@ func (s *Store) CommitPublished(generationID string, proof memory.PublicationPro if !proof.JournalVerified { return fmt.Errorf("%w: journal verified proof is required", ErrPublicationProofInvalid) } + if proof.Version != 0 && proof.Version != 4 { + return fmt.Errorf("%w: unsupported publication proof version", ErrPublicationProofInvalid) + } if !sha256HexPattern.MatchString(strings.ToLower(proof.ReviewSHA256)) || !sha256HexPattern.MatchString(strings.ToLower(proof.HistorySHA256)) || !sha256HexPattern.MatchString(strings.ToLower(proof.LedgerSHA256)) { return fmt.Errorf("%w: public projection file hashes are invalid", ErrPublicationProofInvalid) } + if proof.Version == 4 && !sha256HexPattern.MatchString(strings.ToLower(proof.SessionIndexSHA256)) { + return fmt.Errorf("%w: v4 session index hash is required", ErrPublicationProofInvalid) + } + if proof.Version == 0 && proof.SessionIndexSHA256 != "" { + return fmt.Errorf("%w: legacy publication proof cannot include a session index hash", ErrPublicationProofInvalid) + } return s.withStoreLock(func() error { manifest, err := s.loadGeneration(generationID) diff --git a/internal/memorystore/store_test.go b/internal/memorystore/store_test.go index 49e481a..334689f 100644 --- a/internal/memorystore/store_test.go +++ b/internal/memorystore/store_test.go @@ -1321,3 +1321,50 @@ func TestCommitPublishedRequiresProofAndSwitchesAtomically(t *testing.T) { t.Fatalf("published manifest mismatch: ID=%s manifest=%+v", pubID, pubManifest) } } + +func TestCommitPublishedV4RequiresSessionIndexProof(t *testing.T) { + _, store, fixture := preparedStore(t) + defer store.Close() + prepared, _, err := store.LoadPrepared() + if err != nil { + t.Fatal(err) + } + proof := memory.PublicationProof{ + Version: 4, ProjectID: testProjectID, GenerationID: fixture.manifest.GenerationID, + ManifestDigest: prepared.ManifestDigest, ProjectViewDigest: prepared.ProjectViewDigest, + ReviewSHA256: strings.Repeat("1", 64), HistorySHA256: strings.Repeat("2", 64), LedgerSHA256: strings.Repeat("3", 64), JournalVerified: true, + } + if err := store.CommitPublished(fixture.manifest.GenerationID, proof); !errors.Is(err, ErrPublicationProofInvalid) { + t.Fatalf("v4 proof without session index = %v", err) + } + proof.SessionIndexSHA256 = strings.Repeat("4", 64) + if err := store.CommitPublished(fixture.manifest.GenerationID, proof); err != nil { + t.Fatalf("v4 proof with session index rejected: %v", err) + } +} + +func TestCommitPublishedRejectsAmbiguousPublicationProofVersions(t *testing.T) { + _, store, fixture := preparedStore(t) + defer store.Close() + prepared, _, err := store.LoadPrepared() + if err != nil { + t.Fatal(err) + } + base := memory.PublicationProof{ + ProjectID: testProjectID, GenerationID: fixture.manifest.GenerationID, + ManifestDigest: prepared.ManifestDigest, ProjectViewDigest: prepared.ProjectViewDigest, + ReviewSHA256: strings.Repeat("1", 64), HistorySHA256: strings.Repeat("2", 64), LedgerSHA256: strings.Repeat("3", 64), JournalVerified: true, + } + for _, proof := range []memory.PublicationProof{ + func() memory.PublicationProof { value := base; value.Version = 3; return value }(), + func() memory.PublicationProof { + value := base + value.SessionIndexSHA256 = strings.Repeat("4", 64) + return value + }(), + } { + if err := store.CommitPublished(fixture.manifest.GenerationID, proof); !errors.Is(err, ErrPublicationProofInvalid) { + t.Fatalf("ambiguous proof %+v error=%v", proof, err) + } + } +} diff --git a/internal/migrationv3/plan_test.go b/internal/migrationv3/plan_test.go index 3755dfc..1f79b70 100644 --- a/internal/migrationv3/plan_test.go +++ b/internal/migrationv3/plan_test.go @@ -38,3 +38,22 @@ func TestMigrationV3PlanDeterministic(t *testing.T) { t.Fatalf("expected 2 legacy items, got %d", len(plan1.LegacyItems)) } } + +func TestCompatibilityV2StillUsesMigrationV3Plan(t *testing.T) { + in := Input{ + ProjectID: "project-v2-compatibility", + PreparedGeneration: "generation-v3-target", + AcceptedV2: reviewv2.Accepted{State: reviewv2.State{Review: reviewv2.Review{ + ProjectID: "project-v2-compatibility", + Revision: 2, + Decisions: []reviewv2.Decision{{ID: "decision-v2", Title: "Preserve v2 route", Status: "active"}}, + }}}, + } + plan, err := BuildPlan(context.Background(), in) + if err != nil { + t.Fatal(err) + } + if plan.SourceRevision != 2 || plan.PreparedGeneration != "generation-v3-target" || len(plan.LegacyItems) != 1 || plan.LegacyItems[0].EntityID != "decision-v2" { + t.Fatalf("legacy v2/v3 compatibility plan changed: %+v", plan) + } +} diff --git a/internal/migrationv4/migrate.go b/internal/migrationv4/migrate.go new file mode 100644 index 0000000..1252928 --- /dev/null +++ b/internal/migrationv4/migrate.go @@ -0,0 +1,277 @@ +package migrationv4 + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/neomei/SessionReviewer/internal/pricing" + "github.com/neomei/SessionReviewer/internal/reviewv2" + "github.com/neomei/SessionReviewer/internal/reviewv4" + "github.com/neomei/SessionReviewer/internal/sessionindex" + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +func BuildPreview(input Input) (Result, error) { + acceptedV3, err := reviewv2.LoadV3Bytes(input.Review, input.History, input.Ledger) + if err != nil { + return Result{}, err + } + result, err := migrate(acceptedV3, input.History, input.SessionIndex, input.GenerationID) + if err != nil { + return Result{}, err + } + preview := basePreview(acceptedV3, input.Review, input.History, input.Ledger) + preview.GenerationID = result.Accepted.Review.GenerationID + preview.SessionViewDependencyDigests = sortedUnique(input.SessionViewDependencyDigests) + preview.TargetHashes = ArtifactHashes{Review: digest(result.Review), History: digest(result.History), Ledger: digest(result.Ledger), SessionIndex: digest(result.SessionIndex)} + preimages := absentHashes() + preimages.Review = preimageHash(input.TargetPreimages[ReviewRelativePath]) + preimages.History = preimageHash(input.TargetPreimages[HistoryRelativePath]) + preimages.Ledger = preimageHash(input.TargetPreimages[LedgerRelativePath]) + preimages.SessionIndex = preimageHash(input.TargetPreimages[SessionIndexRelativePath]) + preview.TargetPreimageHashes = preimages + preview.PreviewDigest = MigrationPreviewDigest(preview) + if err := validatePreview(preview); err != nil { + return Result{}, err + } + result.Preview = preview + result.TargetPreimages = clonePreimages(input.TargetPreimages) + return result, nil +} + +func MigrateAcceptedV3(review, history, ledger, sessionIndex []byte) (reviewv4.Accepted, error) { + result, err := MigrateAcceptedV3Result(review, history, ledger, sessionIndex) + return result.Accepted, err +} + +func MigrateAcceptedV3Result(review, history, ledger, sessionIndex []byte) (Result, error) { + accepted, err := reviewv2.LoadV3Bytes(review, history, ledger) + if err != nil { + return Result{}, err + } + return migrate(accepted, history, sessionIndex, "") +} + +func migrate(source reviewv2.AcceptedV3, history, indexBytes []byte, selectedGeneration string) (Result, error) { + index, err := sessionindex.Parse(indexBytes) + if err != nil { + return Result{}, fmt.Errorf("session index: %w", err) + } + machine := source.State.Machine + generationID := machine.GenerationID + if selectedGeneration != "" { + generationID = selectedGeneration + } + projectDigest := "sha256:" + machine.ProjectViewDigest + if index.ProjectID != machine.ProjectID || index.GenerationID != generationID || index.ProjectViewDigest != projectDigest { + return Result{}, errors.New("session index project, generation, or ProjectView does not match v3 source") + } + indexBytes, err = sessionindex.Render(index) + if err != nil { + return Result{}, err + } + index, err = sessionindex.Parse(indexBytes) + if err != nil { + return Result{}, err + } + + presentation, err := migratePresentation(source, generationID, projectDigest) + if err != nil { + return Result{}, err + } + reviewBytes, err := strictjson.Encode(presentation) + if err != nil { + return Result{}, err + } + if _, err := reviewv4.DecodePresentation(reviewBytes); err != nil { + return Result{}, fmt.Errorf("render migrated review: %w", err) + } + + ledger, err := migrateLedger(source, index, generationID, projectDigest, reviewBytes, history) + if err != nil { + return Result{}, err + } + ledgerBytes, err := reviewv4.RenderLedger(ledger) + if err != nil { + return Result{}, fmt.Errorf("render migrated ledger: %w", err) + } + accepted, err := reviewv4.LoadProjection(reviewBytes, history, ledgerBytes, indexBytes) + if err != nil { + return Result{}, fmt.Errorf("validate migrated projection: %w", err) + } + return Result{Review: reviewBytes, History: append([]byte(nil), history...), Ledger: ledgerBytes, SessionIndex: indexBytes, Accepted: accepted}, nil +} + +func migratePresentation(source reviewv2.AcceptedV3, generationID, projectDigest string) (reviewv4.Presentation, error) { + state := source.State + result := reviewv4.Presentation{ + SchemaVersion: 4, MinimumReaderVersion: "0.4.0", MinimumWriterVersion: "0.4.0", + ProjectID: state.Review.ProjectID, GenerationID: generationID, ProjectViewDigest: projectDigest, Revision: state.Review.Revision, + CurrentState: reviewv4.CurrentState{Goal: state.Review.Goal, Stage: state.Review.Stage, Status: state.Review.Status, NextAction: state.Review.NextAction, LastVerification: state.Review.LastVerification}, + Timeline: []reviewv4.Timeline{}, Decisions: []reviewv4.Decision{}, Risks: []reviewv4.Risk{}, OpenLoops: []reviewv4.OpenLoop{}, + HumanPatches: migratePatches(state.Machine.HumanPatches), OrphanPatches: migratePatches(state.Machine.OrphanPatches), GeneratedBaselines: migrateBaselines(state.Machine.GeneratedBaselines, generationID), + } + for _, event := range state.Events { + result.Timeline = append(result.Timeline, reviewv4.Timeline{ID: event.ID, GenerationID: generationID, OccurredAt: event.OccurredAt, Kind: event.Kind, Title: event.Title, Summary: event.Summary, DecisionIDs: append([]string{}, event.DecisionIDs...)}) + } + for _, decision := range state.Review.Decisions { + status, err := migrateDecisionStatus(decision.Status) + if err != nil { + return reviewv4.Presentation{}, fmt.Errorf("decision %q: %w", decision.ID, err) + } + result.Decisions = append(result.Decisions, reviewv4.Decision{ + ID: decision.ID, Kind: "decision", OccurredAt: decision.OccurredAt, Title: decision.Title, Rationale: decision.Rationale, Impact: decision.Impact, Status: status, + ReevaluateWhen: "", Supersedes: []string{}, MilestoneIDs: []string{}, SessionRefs: []reviewv4.SessionRef{}, Provenance: "migrated", Pinned: false, Revision: 1, + }) + } + for _, risk := range state.Review.Risks { + result.Risks = append(result.Risks, reviewv4.Risk{ID: risk.ID, Title: risk.Title, Status: risk.Status, Detail: risk.Detail}) + } + for _, loop := range state.Machine.LegacyCompatibility.OpenLoops { + result.OpenLoops = append(result.OpenLoops, reviewv4.OpenLoop{ID: loop.ID, Title: loop.Title, Status: loop.Status, Question: loop.Question, NextExperiment: loop.NextExperiment, CompletionCriterion: loop.CompletionCriterion}) + } + if err := reviewv4.ValidatePresentation(result); err != nil { + return reviewv4.Presentation{}, fmt.Errorf("migrated presentation: %w", err) + } + return result, nil +} + +func migrateDecisionStatus(status string) (reviewv4.DecisionStatus, error) { + switch status { + case "", "active": + return reviewv4.DecisionActive, nil + case "archived": + return reviewv4.DecisionArchived, nil + case "superseded": + return "", errors.New("superseded status cannot be represented without inventing a successor") + default: + return "", fmt.Errorf("legacy decision status %q has no exact v4 mapping", status) + } +} + +func migratePatches(values []reviewv2.HumanPatchWire) []reviewv4.Patch { + result := make([]reviewv4.Patch, 0, len(values)) + for _, value := range values { + patch := reviewv4.Patch{EntityID: value.EntityID, Field: value.Field, Operation: value.Operation, BaseGeneratedHash: value.BaseGeneratedHash} + if value.Operation == "set" { + if value.Values != nil { + values := append([]string{}, value.Values...) + patch.Values = &values + } else { + scalar := value.Value + patch.Value = &scalar + } + } + result = append(result, patch) + } + return result +} + +func migrateBaselines(values []reviewv2.GeneratedBaselineWire, generationID string) []reviewv4.Baseline { + result := make([]reviewv4.Baseline, 0, len(values)) + for _, value := range values { + baseline := reviewv4.Baseline{GenerationID: generationID, EntityID: value.EntityID, Field: value.Field, Kind: value.Kind, GeneratedHash: value.GeneratedHash} + if value.Kind == "list" { + values := append([]string{}, value.Values...) + baseline.Values = &values + } else { + scalar := value.Value + baseline.Value = &scalar + } + result = append(result, baseline) + } + return result +} + +func migrateLedger(source reviewv2.AcceptedV3, index sessionindex.Document, generationID, projectDigest string, reviewBytes, history []byte) (reviewv4.MachineLedger, error) { + machine := source.State.Machine + duration, tokens, err := nonnegative(machine.Accounting.TotalDurationMS, machine.Accounting.TotalTokens) + if err != nil { + return reviewv4.MachineLedger{}, err + } + models := make([]reviewv4.Model, 0, len(machine.Accounting.Models)) + for _, model := range machine.Accounting.Models { + value, _, err := nonnegative(model.TotalTokens, 0) + if err != nil { + return reviewv4.MachineLedger{}, err + } + models = append(models, reviewv4.Model{Model: model.Model, TotalTokens: value, TotalCostUSD: nil}) + } + sort.Slice(models, func(i, j int) bool { return models[i].Model < models[j].Model }) + sessions := make([]reviewv4.LedgerSession, 0, len(index.Sessions)) + indexBySession := make(map[string]sessionindex.Entry, len(index.Sessions)) + for _, entry := range index.Sessions { + state := reviewv4.ProcessingState(entry.ProcessingState) + sessions = append(sessions, reviewv4.LedgerSession{Provider: entry.Provider, SessionID: entry.SessionID, ProcessingState: state, SourceAvailability: entry.SourceAvailability, SessionViewDigest: cloneString(entry.SessionViewDigest), UsageRecordDigest: cloneString(entry.UsageRecordDigest)}) + indexBySession[entry.SessionID] = entry + } + pricingSnapshots := make([]pricing.Snapshot, 0) + for _, session := range machine.Sessions { + entry, exists := indexBySession[session.SessionID] + if !exists || entry.UsageRecordDigest == nil || session.Accounting == nil { + continue + } + for modelIndex, model := range session.Accounting.Models { + uncachedInput := model.InputTokens - model.CachedInputTokens - model.CacheWriteInputTokens + quantities := pricing.Quantities{ + Input: uint64(uncachedInput), CachedInput: uint64(model.CachedInputTokens), CacheWriteInput: uint64(model.CacheWriteInputTokens), + Output: uint64(model.OutputTokens), ReasoningOutput: uint64(model.ReasoningOutputTokens), + } + missing := make([]string, 0, 5) + for _, dimension := range []struct { + name string + value uint64 + }{{"input", quantities.Input}, {"cached_input", quantities.CachedInput}, {"cache_write_input", quantities.CacheWriteInput}, {"output", quantities.Output}, {"reasoning_output", quantities.ReasoningOutput}} { + if dimension.value > 0 { + missing = append(missing, dimension.name) + } + } + snapshotSeed := fmt.Sprintf("%s\x00%s\x00%s\x00%d", entry.Provider, entry.SessionID, model.Model, modelIndex) + pricingSnapshots = append(pricingSnapshots, pricing.Snapshot{ + SchemaVersion: 1, MinimumReaderVersion: "0.4.0", SnapshotID: "legacy-" + strings.TrimPrefix(digest([]byte(snapshotSeed)), "sha256:")[:32], + ProjectID: machine.ProjectID, Provider: entry.Provider, SessionID: entry.SessionID, UsageRecordDigest: *entry.UsageRecordDigest, + BillingHost: "legacy", BilledModelID: model.Model, BillingMode: "legacy", BillingRuleVersion: "legacy-v3", + PricedAt: session.Accounting.EndedAt, CreatedAt: session.Accounting.EndedAt, Status: pricing.PriceLegacyUnverified, + SourceKind: "unresolved", Rates: pricing.Rates{}, BillableQuantities: quantities, LineCostsUSD: pricing.LineCosts{}, + MissingBillingDimensions: missing, KnownSubtotalUSD: 0, TotalCostUSD: nil, PricingComplete: false, + AuditReason: "Migrated from v3; original price source and date are not independently verifiable.", + }) + } + } + sort.Slice(pricingSnapshots, func(i, j int) bool { return pricingSnapshots[i].SnapshotID < pricingSnapshots[j].SnapshotID }) + ledger := reviewv4.MachineLedger{ + SchemaVersion: 4, MinimumReaderVersion: "0.4.0", MinimumWriterVersion: "0.4.0", ProjectID: machine.ProjectID, GenerationID: generationID, ProjectViewDigest: projectDigest, + AcceptedRevision: machine.AcceptedRevision, ReviewSHA256: strings.TrimPrefix(digest(reviewBytes), "sha256:"), HistorySHA256: strings.TrimPrefix(digest(history), "sha256:"), + Accounting: reviewv4.Accounting{TotalDurationMS: duration, TotalTokens: tokens, TotalCostUSD: nil, Models: models}, Sessions: sessions, + HumanPatches: migratePatches(machine.HumanPatches), OrphanPatches: migratePatches(machine.OrphanPatches), GeneratedBaselines: migrateBaselines(machine.GeneratedBaselines, generationID), + PricingSnapshots: pricingSnapshots, CurrentPricingSnapshotIDs: []string{}, + } + ledger.SyncHashes = reviewv4.SyncHashes{ReviewSHA256: ledger.ReviewSHA256, HistorySHA256: ledger.HistorySHA256, LedgerSHA256: strings.Repeat("0", 64), SessionIndexDigest: index.Digest} + return ledger, nil +} + +func nonnegative(first, second int64) (uint64, uint64, error) { + if first < 0 || second < 0 { + return 0, 0, errors.New("legacy accounting contains a negative value") + } + return uint64(first), uint64(second), nil +} + +func cloneString(value *string) *string { + if value == nil { + return nil + } + copy := *value + return © +} + +func clonePreimages(values map[string]Preimage) map[string]Preimage { + result := make(map[string]Preimage, len(values)) + for relative, value := range values { + value.Bytes = append([]byte(nil), value.Bytes...) + result[relative] = value + } + return result +} diff --git a/internal/migrationv4/migrate_test.go b/internal/migrationv4/migrate_test.go new file mode 100644 index 0000000..2e02e4e --- /dev/null +++ b/internal/migrationv4/migrate_test.go @@ -0,0 +1,402 @@ +package migrationv4 + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/neomei/SessionReviewer/internal/accounting" + "github.com/neomei/SessionReviewer/internal/ledger" + "github.com/neomei/SessionReviewer/internal/reviewv2" + "github.com/neomei/SessionReviewer/internal/reviewv4" + "github.com/neomei/SessionReviewer/internal/sessionindex" +) + +type compatibilityFixture struct { + Case string `json:"case"` + SourceVersion int `json:"source_version"` + TargetVersion int `json:"target_version"` + ReviewVersion int `json:"review_version"` + HistoryVersion int `json:"history_version"` + LedgerVersion int `json:"ledger_version"` + IndexVersion int `json:"index_version"` + ExpectedReader string `json:"expected_reader"` + ExpectedRoute string `json:"expected_route"` + OrdinarySyncError string `json:"ordinary_sync_error"` + ConfirmationRequired bool `json:"confirmation_required"` + Present []string `json:"present"` + Missing string `json:"missing"` + Expected string `json:"expected"` +} + +func TestCompatibilityMatrixFixturesExerciseRealReaders(t *testing.T) { + fixtures := loadCompatibilityFixtures(t) + if got := fixtures["v2"]; got.SourceVersion != 2 || got.ExpectedReader != "reviewv2" || got.ExpectedRoute != "migrationv3" { + t.Fatalf("unexpected v2 fixture: %+v", got) + } + for _, path := range []string{"../../testdata/review-v2/项目回顾.valid.md", "../../testdata/review-v2/项目历史.valid.md", "../../testdata/review-v2/ledger.valid.json"} { + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + switch filepath.Base(path) { + case "项目回顾.valid.md": + if _, err := reviewv2.ParseReview(body); err != nil { + t.Fatalf("v2 review fixture is no longer readable: %v", err) + } + case "项目历史.valid.md": + if _, err := reviewv2.ParseHistory(body); err != nil { + t.Fatalf("v2 history fixture is no longer readable: %v", err) + } + default: + if _, err := reviewv2.ParseMachineLedger(body); err != nil { + t.Fatalf("v2 ledger fixture is no longer readable: %v", err) + } + } + } + + review, history, machine, index := migrationFixture(t) + v3 := fixtures["v3"] + if v3.SourceVersion != 3 || v3.TargetVersion != 4 || v3.OrdinarySyncError != "migration_required" || !v3.ConfirmationRequired { + t.Fatalf("unexpected v3 fixture: %+v", v3) + } + if _, err := reviewv2.LoadV3Bytes(review, history, machine); err != nil { + t.Fatalf("v3 strict read failed: %v", err) + } + result, err := MigrateAcceptedV3Result(review, history, machine, index) + if err != nil { + t.Fatal(err) + } + v4 := fixtures["v4"] + if v4.ReviewVersion != 4 || v4.LedgerVersion != 4 || v4.IndexVersion != 1 || v4.ExpectedReader != "reviewv4.LoadProjection" { + t.Fatalf("unexpected v4 fixture: %+v", v4) + } + if _, err := reviewv4.LoadProjection(result.Review, result.History, result.Ledger, result.SessionIndex); err != nil { + t.Fatalf("v4 direct open failed: %v", err) + } + + newer := fixtures["newer"] + newerReview := bytes.Replace(review, []byte("schema_version: 3"), []byte(fmt.Sprintf("schema_version: %d", newer.SourceVersion)), 1) + if newer.Expected != "reject" || newer.SourceVersion <= 4 { + t.Fatalf("unexpected newer fixture: %+v", newer) + } + if _, err := PreviewMigration(newerReview, history, machine); err == nil { + t.Fatal("newer fixture was accepted") + } + + partial := fixtures["partial"] + if partial.Expected != "reject" || partial.Missing != "session_index" || len(partial.Present) != 3 { + t.Fatalf("unexpected partial fixture: %+v", partial) + } + if _, err := MigrateAcceptedV3(review, history, machine, nil); err == nil { + t.Fatal("partial fixture was accepted") + } + + mixed := fixtures["mixed"] + mixedLedger := bytes.Replace(machine, []byte(`"schema_version": 3`), []byte(fmt.Sprintf(`"schema_version": %d`, mixed.LedgerVersion)), 1) + if mixed.Expected != "reject" || mixed.ReviewVersion != 3 || mixed.HistoryVersion != 3 || mixed.LedgerVersion != 4 || mixed.IndexVersion != 1 { + t.Fatalf("unexpected mixed fixture: %+v", mixed) + } + if _, err := PreviewMigration(review, history, mixedLedger); err == nil { + t.Fatal("mixed fixture was accepted") + } +} + +func loadCompatibilityFixtures(t *testing.T) map[string]compatibilityFixture { + t.Helper() + result := make(map[string]compatibilityFixture) + paths, err := filepath.Glob("../../testdata/contracts/migration/*.json") + if err != nil { + t.Fatal(err) + } + for _, path := range paths { + body, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var fixture compatibilityFixture + if err := json.Unmarshal(body, &fixture); err != nil { + t.Fatalf("%s: %v", path, err) + } + if fixture.Case == "" || result[fixture.Case].Case != "" { + t.Fatalf("%s: missing or duplicate case %q", path, fixture.Case) + } + result[fixture.Case] = fixture + } + if len(result) != 6 { + t.Fatalf("compatibility fixture count = %d, want 6", len(result)) + } + return result +} + +func TestMigrateAcceptedV3PreservesDecisionWithoutInventingFields(t *testing.T) { + review, history, machine, index := migrationFixture(t) + result, err := MigrateAcceptedV3Result(review, history, machine, index) + if err != nil { + t.Fatal(err) + } + decision := result.Accepted.Review.Decisions[0] + if decision.ID != "decision-1" || decision.Title != "Keep v3" || decision.Rationale != "because" || decision.Impact != "scope" || + decision.Kind != "decision" || decision.Status != reviewv4.DecisionActive || decision.Provenance != "migrated" || decision.Pinned || decision.Revision != 1 || + decision.ReevaluateWhen != "" || len(decision.Supersedes) != 0 || len(decision.MilestoneIDs) != 0 || len(decision.SessionRefs) != 0 { + t.Fatalf("migration lost or invented decision data: %+v", decision) + } + if _, err := reviewv4.LoadProjection(result.Review, result.History, result.Ledger, result.SessionIndex); err != nil { + t.Fatalf("migrated four-file projection is not mutually bound: %v", err) + } +} + +func TestMigrateAcceptedV3PreservesLegacyUsageAsUnverifiedPricingEvidence(t *testing.T) { + review, history, machine, indexBody := migrationFixture(t) + source, err := reviewv2.LoadV3Bytes(review, history, machine) + if err != nil { + t.Fatal(err) + } + account := &accounting.SessionAccounting{ + StartedAt: "2026-09-04T00:00:00Z", EndedAt: "2026-09-04T00:01:00Z", DurationMS: 60_000, + Models: []accounting.ModelAccounting{{ + ModelUsage: accounting.ModelUsage{Model: "legacy-model", TokenUsage: accounting.TokenUsage{InputTokens: 10, OutputTokens: 5, TotalTokens: 15}}, + Pricing: accounting.Pricing{Currency: "USD", InputPerMillion: 1, OutputPerMillion: 2, Source: "https://example.test/legacy-price", AsOf: "2026-09-04"}, + CostUSD: 0.00002, + }}, + TotalTokens: 15, TotalCostUSD: 0.00002, + } + source.State.Machine.Sessions = []ledger.SessionReport{{ID: "report-legacy", ProjectID: source.State.Machine.ProjectID, SessionID: "session-legacy", Accounting: account}} + source.State.Machine.Accounting = accounting.ProjectSummary{ + TotalDurationMS: 60_000, TotalTokens: 15, TotalCostUSD: 0.00002, + Models: []accounting.ProjectModelSummary{{Model: "legacy-model", TotalTokens: 15, TotalCostUSD: 0.00002, TokenSharePct: 100, CostSharePct: 100}}, + } + index, err := sessionindex.Parse(indexBody) + if err != nil { + t.Fatal(err) + } + duration, records := uint64(60_000), uint64(1) + terminal := "indexed" + sessionDigest, usageDigest := "sha256:"+strings.Repeat("2", 64), "sha256:"+strings.Repeat("3", 64) + lastGeneration := index.GenerationID + index.Sessions = []sessionindex.Entry{{ + Provider: "codex", SessionID: "session-legacy", ProcessingState: sessionindex.ProcessingComplete, + StateReasonCodes: []string{}, SourceAvailability: "available", SourceTerminalState: &terminal, + StartedAt: account.StartedAt, EndedAt: account.EndedAt, DurationMS: &duration, RecordCount: &records, + Coverage: sessionindex.Coverage{}, FactCounts: sessionindex.FactCounts{}, SessionViewDigest: &sessionDigest, UsageRecordDigest: &usageDigest, + LastSeenGenerationID: &lastGeneration, LastSuccessfulGenerationID: &lastGeneration, + }} + index.Coverage = sessionindex.IndexCoverage{Total: 1, Complete: 1, SourceAvailable: 1, StartedAtKnown: 1, EndedAtKnown: 1, UsageKnown: 1} + indexBody, err = sessionindex.Render(index) + if err != nil { + t.Fatal(err) + } + result, err := migrate(source, history, indexBody, "") + if err != nil { + t.Fatal(err) + } + if len(result.Accepted.Ledger.PricingSnapshots) != 1 { + t.Fatalf("pricing snapshots = %+v", result.Accepted.Ledger.PricingSnapshots) + } + snapshot := result.Accepted.Ledger.PricingSnapshots[0] + if snapshot.Status != "legacy_unverified" || snapshot.PricingComplete || snapshot.TotalCostUSD != nil || snapshot.SourceKind != "unresolved" || snapshot.SourceURL != nil || snapshot.BillableQuantities.Input != 10 || snapshot.BillableQuantities.Output != 5 { + t.Fatalf("legacy pricing evidence was upgraded or lost: %+v", snapshot) + } + if len(result.Accepted.Ledger.CurrentPricingSnapshotIDs) != 0 || result.Accepted.Ledger.Accounting.TotalCostUSD != nil || result.Accepted.Ledger.Accounting.Models[0].TotalCostUSD != nil { + t.Fatalf("unverified legacy pricing was claimed current or complete: %+v", result.Accepted.Ledger.Accounting) + } +} + +func TestMigrationPreviewBindsEveryFreshnessInputAndIsDeterministic(t *testing.T) { + review, history, machine, index := migrationFixture(t) + input := Input{ + Review: review, History: history, Ledger: machine, SessionIndex: index, + SessionViewDependencyDigests: []string{"sha256:" + strings.Repeat("2", 64)}, + TargetPreimages: map[string]Preimage{ + ReviewRelativePath: {Exists: true, Bytes: review}, + }, + } + first, err := BuildPreview(input) + if err != nil { + t.Fatal(err) + } + second, err := BuildPreview(input) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(first.Review, second.Review) || !bytes.Equal(first.History, second.History) || !bytes.Equal(first.Ledger, second.Ledger) || !bytes.Equal(first.SessionIndex, second.SessionIndex) || first.Preview.PreviewDigest != second.Preview.PreviewDigest { + t.Fatal("repeated migration was not byte stable") + } + if first.Preview.SchemaVersion != 1 || first.Preview.SourceVersion != 3 || first.Preview.TargetVersion != 4 || !first.Preview.RequiresSessionIndex { + t.Fatalf("unexpected preview contract: %+v", first.Preview) + } + if got := first.Preview.TargetPreimageHashes.SessionIndex; got != AbsentPreimageSHA256 { + t.Fatalf("missing target preimage = %q", got) + } + mutations := []func(*Input){ + func(in *Input) { in.Review = append(append([]byte(nil), in.Review...), '\n') }, + func(in *Input) { in.SessionViewDependencyDigests = []string{"sha256:" + strings.Repeat("3", 64)} }, + func(in *Input) { + in.TargetPreimages[ReviewRelativePath] = Preimage{Exists: true, Bytes: []byte("changed")} + }, + } + for i, mutate := range mutations { + changed := cloneInput(input) + mutate(&changed) + got, err := BuildPreview(changed) + if err != nil { + t.Fatal(err) + } + if got.Preview.PreviewDigest == first.Preview.PreviewDigest { + t.Fatalf("freshness mutation %d did not change preview digest", i) + } + } +} + +func TestPreviewMigrationRejectsMixedOrNewerSources(t *testing.T) { + review, history, machine, _ := migrationFixture(t) + if _, err := PreviewMigration(review, history, machine); err != nil { + t.Fatalf("v3 preview rejected: %v", err) + } + newer := bytes.Replace(review, []byte("schema_version: 3"), []byte("schema_version: 4"), 1) + if _, err := PreviewMigration(newer, history, machine); err == nil { + t.Fatal("mixed/newer source was accepted") + } + if _, err := PreviewMigration(review, nil, machine); err == nil { + t.Fatal("partial source was accepted") + } + for name, source := range map[string][3][]byte{ + "missing review": {nil, history, machine}, + "missing history": {review, nil, machine}, + "missing ledger": {review, history, nil}, + "newer history": {review, bytes.Replace(history, []byte("schema_version: 3"), []byte("schema_version: 4"), 1), machine}, + "newer ledger": {review, history, bytes.Replace(machine, []byte(`"schema_version": 3`), []byte(`"schema_version": 4`), 1)}, + } { + t.Run(name, func(t *testing.T) { + if _, err := PreviewMigration(source[0], source[1], source[2]); err == nil { + t.Fatal("incompatible source was accepted") + } + }) + } +} + +func TestMigrationPreviewRejectsStaleDigestAndInvalidBindings(t *testing.T) { + review, history, machine, index := migrationFixture(t) + result, err := BuildPreview(Input{Review: review, History: history, Ledger: machine, SessionIndex: index}) + if err != nil { + t.Fatal(err) + } + mutations := []func(*MigrationPreview){ + func(preview *MigrationPreview) { preview.SourceHashes.Review = "sha256:" + strings.Repeat("f", 64) }, + func(preview *MigrationPreview) { preview.GenerationID = "generation-other" }, + func(preview *MigrationPreview) { preview.TargetPreimageHashes.SessionIndex = "invalid" }, + func(preview *MigrationPreview) { preview.PreviewDigest = "sha256:" + strings.Repeat("0", 64) }, + } + for index, mutate := range mutations { + preview := result.Preview + mutate(&preview) + if err := validatePreview(preview); err == nil { + t.Fatalf("invalid/stale preview mutation %d was accepted", index) + } + } +} + +func TestMigrateAcceptedV3RejectsMixedProjectGenerationAndUnmappableStatus(t *testing.T) { + review, history, machine, index := migrationFixture(t) + parsedIndex, err := sessionindex.Parse(index) + if err != nil { + t.Fatal(err) + } + parsedIndex.ProjectID = "project-other" + wrongProject, err := sessionindex.Render(parsedIndex) + if err != nil { + t.Fatal(err) + } + if _, err := MigrateAcceptedV3(review, history, machine, wrongProject); err == nil { + t.Fatal("mixed project index was accepted") + } + parsedIndex.ProjectID = "project-migration" + parsedIndex.GenerationID = "generation-other" + wrongGeneration, err := sessionindex.Render(parsedIndex) + if err != nil { + t.Fatal(err) + } + if _, err := MigrateAcceptedV3(review, history, machine, wrongGeneration); err == nil { + t.Fatal("mixed generation index was accepted") + } + + badStatus := bytes.Replace(review, []byte("#### \u72b6\u6001\nactive"), []byte("#### \u72b6\u6001\nmaybe"), 1) + badMachine := rebindV3ReviewHash(t, machine, badStatus) + if _, err := MigrateAcceptedV3(badStatus, history, badMachine, index); err == nil { + t.Fatal("unmappable decision status was accepted") + } +} + +func rebindV3ReviewHash(t *testing.T, machine, review []byte) []byte { + t.Helper() + value, err := reviewv2.ParseMachineLedgerV3(machine) + if err != nil { + t.Fatal(err) + } + value.ReviewSHA256 = bareHash(review) + body, err := reviewv2.RenderMachineLedgerV3(value) + if err != nil { + t.Fatal(err) + } + return body +} + +func cloneInput(input Input) Input { + result := input + result.Review = append([]byte(nil), input.Review...) + result.History = append([]byte(nil), input.History...) + result.Ledger = append([]byte(nil), input.Ledger...) + result.SessionIndex = append([]byte(nil), input.SessionIndex...) + result.SessionViewDependencyDigests = append([]string(nil), input.SessionViewDependencyDigests...) + result.TargetPreimages = make(map[string]Preimage, len(input.TargetPreimages)) + for key, value := range input.TargetPreimages { + value.Bytes = append([]byte(nil), value.Bytes...) + result.TargetPreimages[key] = value + } + return result +} + +func migrationFixture(t *testing.T) ([]byte, []byte, []byte, []byte) { + t.Helper() + projectID := "project-migration" + generationID := "generation-migration" + reviewModel := reviewv2.Review{ + ProjectID: projectID, GenerationID: generationID, MinimumWriterVersion: reviewv2.MinimumWriterVersion, + Revision: 3, Name: "Migration", Goal: "Preserve", Stage: "implementation", Status: "active", NextAction: "verify", LastVerification: "2026-09-04", + Risks: []reviewv2.Risk{}, Decisions: []reviewv2.Decision{{ID: "decision-1", OccurredAt: "2026-09-04", Title: "Keep v3", Rationale: "because", Impact: "scope", Status: "active"}}, + } + events := []reviewv2.Event{{ID: "event-1", GenerationID: generationID, OccurredAt: "2026-09-04", Kind: "verification", Title: "Verified", Meaning: "meaning", Summary: "summary", Why: "why", Next: "next", Changes: []string{"change"}, Results: []string{"passed"}, DecisionIDs: []string{"decision-1"}}} + reviewBody, err := reviewv2.RenderReviewV3(reviewModel) + if err != nil { + t.Fatal(err) + } + historyBody, err := reviewv2.RenderHistoryV3(projectID, reviewModel.Revision, generationID, events) + if err != nil { + t.Fatal(err) + } + machineBody, err := reviewv2.RenderMachineLedgerV3(reviewv2.MachineLedgerV3{ + SchemaVersion: 3, MinimumWriterVersion: reviewv2.MinimumWriterVersion, ProjectID: projectID, GenerationID: generationID, + ProjectViewDigest: strings.Repeat("1", 64), AcceptedRevision: reviewModel.Revision, ReviewSHA256: bareHash(reviewBody), HistorySHA256: bareHash(historyBody), + Accounting: accounting.ProjectSummary{Models: []accounting.ProjectModelSummary{}}, Sessions: []ledger.SessionReport{}, HumanPatches: []reviewv2.HumanPatchWire{}, OrphanPatches: []reviewv2.HumanPatchWire{}, GeneratedBaselines: []reviewv2.GeneratedBaselineWire{}, + LegacyCompatibility: reviewv2.LegacyCompatibility{Timeline: []ledger.TimelineEvent{}, Decisions: []ledger.Decision{}, OpenLoops: []ledger.OpenLoop{}, CurrentRisks: []reviewv2.CurrentRiskProvenance{}}, + }) + if err != nil { + t.Fatal(err) + } + indexBody, err := sessionindex.Render(sessionindex.Document{ + SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: projectID, GenerationID: generationID, + ProjectViewDigest: "sha256:" + strings.Repeat("1", 64), GeneratedAt: "2026-09-04T00:00:00Z", SortVersion: sessionindex.SortVersion, + Sessions: []sessionindex.Entry{}, Coverage: sessionindex.IndexCoverage{}, + }) + if err != nil { + t.Fatal(err) + } + return reviewBody, historyBody, machineBody, indexBody +} + +func bareHash(body []byte) string { return fmt.Sprintf("%x", sha256.Sum256(body)) } diff --git a/internal/migrationv4/plan.go b/internal/migrationv4/plan.go new file mode 100644 index 0000000..761861f --- /dev/null +++ b/internal/migrationv4/plan.go @@ -0,0 +1,141 @@ +package migrationv4 + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "regexp" + "sort" + + "github.com/neomei/SessionReviewer/internal/reviewv2" +) + +var previewDigestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +var decisionDefaultFields = []string{ + "kind=decision", "milestone_ids=[]", "pinned=false", "provenance=migrated", + "reevaluate_when=", "revision=1", "session_refs=[]", "supersedes=[]", +} + +// PreviewMigration validates a complete authenticated v3 source and returns +// its semantic-only preview. Target hashes remain empty until BuildPreview is +// supplied the required session index. +func PreviewMigration(review, history, ledger []byte) (MigrationPreview, error) { + accepted, err := reviewv2.LoadV3Bytes(review, history, ledger) + if err != nil { + return MigrationPreview{}, err + } + preview := basePreview(accepted, review, history, ledger) + preview.TargetPreimageHashes = absentHashes() + preview.PreviewDigest = MigrationPreviewDigest(preview) + return preview, nil +} + +func basePreview(accepted reviewv2.AcceptedV3, review, history, ledger []byte) MigrationPreview { + ids := make([]string, 0, len(accepted.State.Review.Decisions)) + defaults := make(map[string][]string, len(accepted.State.Review.Decisions)) + for _, decision := range accepted.State.Review.Decisions { + ids = append(ids, decision.ID) + defaults[decision.ID] = append([]string(nil), decisionDefaultFields...) + } + sort.Strings(ids) + return MigrationPreview{ + SchemaVersion: 1, SourceVersion: 3, TargetVersion: 4, + ProjectID: accepted.State.Review.ProjectID, GenerationID: accepted.State.Review.GenerationID, + PreservedDecisionIDs: ids, DefaultedFields: defaults, RequiresSessionIndex: true, + SourceHashes: ArtifactHashes{Review: digest(review), History: digest(history), Ledger: digest(ledger)}, + SessionViewDependencyDigests: []string{}, + } +} + +// MigrationPreviewDigest authenticates canonical preview JSON with the digest +// field itself omitted. +func MigrationPreviewDigest(preview MigrationPreview) string { + preview.PreviewDigest = "" + normalizePreview(&preview) + body, err := json.Marshal(preview) + if err != nil { + return "" + } + return digest(body) +} + +func validatePreview(preview MigrationPreview) error { + if preview.SchemaVersion != 1 || preview.SourceVersion != 3 || preview.TargetVersion != 4 || preview.ProjectID == "" || preview.GenerationID == "" || !preview.RequiresSessionIndex { + return errors.New("invalid migration preview metadata") + } + for _, value := range []string{ + preview.SourceHashes.Review, preview.SourceHashes.History, preview.SourceHashes.Ledger, + preview.TargetHashes.Review, preview.TargetHashes.History, preview.TargetHashes.Ledger, preview.TargetHashes.SessionIndex, + } { + if !previewDigestPattern.MatchString(value) { + return errors.New("migration preview contains an invalid artifact hash") + } + } + for _, value := range []string{ + preview.TargetPreimageHashes.Review, preview.TargetPreimageHashes.History, + preview.TargetPreimageHashes.Ledger, preview.TargetPreimageHashes.SessionIndex, + } { + if value != AbsentPreimageSHA256 && !previewDigestPattern.MatchString(value) { + return errors.New("migration preview contains an invalid target preimage hash") + } + } + for _, value := range preview.SessionViewDependencyDigests { + if !previewDigestPattern.MatchString(value) { + return errors.New("migration preview contains an invalid SessionView dependency digest") + } + } + if MigrationPreviewDigest(preview) != preview.PreviewDigest { + return errors.New("migration preview digest mismatch") + } + return nil +} + +func normalizePreview(preview *MigrationPreview) { + sort.Strings(preview.PreservedDecisionIDs) + if preview.PreservedDecisionIDs == nil { + preview.PreservedDecisionIDs = []string{} + } + if preview.DefaultedFields == nil { + preview.DefaultedFields = map[string][]string{} + } + for key, values := range preview.DefaultedFields { + copyValues := append([]string(nil), values...) + sort.Strings(copyValues) + preview.DefaultedFields[key] = copyValues + } + preview.SessionViewDependencyDigests = sortedUnique(preview.SessionViewDependencyDigests) +} + +func sortedUnique(values []string) []string { + result := append([]string(nil), values...) + sort.Strings(result) + if result == nil { + return []string{} + } + for index := 1; index < len(result); { + if result[index] == result[index-1] { + result = append(result[:index], result[index+1:]...) + } else { + index++ + } + } + return result +} + +func digest(body []byte) string { + sum := sha256.Sum256(body) + return "sha256:" + hex.EncodeToString(sum[:]) +} + +func preimageHash(value Preimage) string { + if !value.Exists { + return AbsentPreimageSHA256 + } + return digest(value.Bytes) +} + +func absentHashes() ArtifactHashes { + return ArtifactHashes{Review: AbsentPreimageSHA256, History: AbsentPreimageSHA256, Ledger: AbsentPreimageSHA256, SessionIndex: AbsentPreimageSHA256} +} diff --git a/internal/migrationv4/types.go b/internal/migrationv4/types.go new file mode 100644 index 0000000..4c5d5c8 --- /dev/null +++ b/internal/migrationv4/types.go @@ -0,0 +1,68 @@ +// Package migrationv4 implements the explicit, digest-bound v3 to v4 +// projection migration. It deliberately does not share the legacy v2/v3 +// migration journal. +package migrationv4 + +import "github.com/neomei/SessionReviewer/internal/reviewv4" + +const ( + ReviewRelativePath = "docs/session-review/项目回顾.md" + HistoryRelativePath = "docs/session-review/项目历史.md" + LedgerRelativePath = "docs/session-review/.session-reviewer/ledger.json" + SessionIndexRelativePath = "docs/session-review/.session-reviewer/session-index.json" + AbsentPreimageSHA256 = "absent" +) + +// ArtifactHashes names every member of the v4 public projection atom. +type ArtifactHashes struct { + Review string `json:"review"` + History string `json:"history"` + Ledger string `json:"ledger"` + SessionIndex string `json:"session_index"` +} + +type MigrationPreview struct { + SchemaVersion int `json:"schema_version"` + SourceVersion int `json:"source_version"` + TargetVersion int `json:"target_version"` + ProjectID string `json:"project_id"` + GenerationID string `json:"generation_id"` + PreservedDecisionIDs []string `json:"preserved_decision_ids"` + DefaultedFields map[string][]string `json:"defaulted_fields"` + RequiresSessionIndex bool `json:"requires_session_index"` + SourceHashes ArtifactHashes `json:"source_hashes"` + SessionViewDependencyDigests []string `json:"session_view_dependency_digests"` + TargetHashes ArtifactHashes `json:"target_hashes"` + TargetPreimageHashes ArtifactHashes `json:"target_preimage_hashes"` + PreviewDigest string `json:"preview_digest"` +} + +type Preimage struct { + Exists bool + Bytes []byte +} + +// Input contains every value that confirmation must recompute while holding +// the project lock. TargetPreimages is keyed by the four RelativePath constants. +type Input struct { + Review []byte + History []byte + Ledger []byte + SessionIndex []byte + GenerationID string + SessionViewDependencyDigests []string + TargetPreimages map[string]Preimage +} + +// Result is the complete deterministic four-file migration plan. +type Result struct { + Review []byte + History []byte + Ledger []byte + SessionIndex []byte + Preview MigrationPreview + Accepted reviewv4.Accepted + // TargetPreimages are the exact bytes authenticated by PreviewDigest and + // must be forwarded unchanged to the publication transaction. + TargetPreimages map[string]Preimage +} diff --git a/internal/publication/service.go b/internal/publication/service.go index 4de944f..2b2ea5f 100644 --- a/internal/publication/service.go +++ b/internal/publication/service.go @@ -34,8 +34,17 @@ type Options struct { Mapping config.ProjectMapping DataRoot string Now func() time.Time + + checkpoint func(publishCheckpoint, string, string) error } +type publishCheckpoint string + +const ( + checkpointAfterDestination publishCheckpoint = "after_destination" + checkpointBeforePointerCommit publishCheckpoint = "before_pointer_commit" +) + // VerifiedFile captures one verified file on disk after publication. type VerifiedFile struct { Side string `json:"side"` @@ -67,6 +76,8 @@ var ( ErrPublicationConflict = errors.New("publication conflict") ) +const sessionIndexRelativePath = "docs/session-review/.session-reviewer/session-index.json" + // Publish executes the complete durable cross-root publication workflow. func Publish(ctx context.Context, opts Options) (Result, error) { if ctx == nil { @@ -240,8 +251,16 @@ func Publish(ctx context.Context, opts Options) (Result, error) { return Result{}, fmt.Errorf("create journal intent: %w", err) } + projectionVersion, err := planProjectionVersion(opts.Plan) + if err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } + // Write Project files for _, file := range opts.Plan.Files { + if err := verifyDestinationPreimage(intent.Destinations, projectDir, "project", file.Relative); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } parentDir := filepath.ToSlash(filepath.Dir(file.Relative)) if parentDir != "." && parentDir != "" { if err := projectDir.EnsureDirectory(parentDir, 0o755); err != nil { @@ -251,25 +270,109 @@ func Publish(ctx context.Context, opts Options) (Result, error) { if err := atomicfile.WriteRoot(projectDir.Root, file.Relative, file.Desired, file.Mode); err != nil { return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("write project file %q: %w", file.Relative, err)) } + if err := runPublishCheckpoint(opts, checkpointAfterDestination, "project", file.Relative); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } } if err := j.Advance(StagePrepared, StageProjectWritten); err != nil { return Result{}, rollbackFailure(ctx, intent, err) } - // Ensure sync scaffold directories and lock + if projectionVersion == 3 { + if err := publishLegacyV3(ctx, opts, intent, rollbackFailure, now); err != nil { + return Result{}, err + } + } else { + for _, file := range opts.Plan.Files { + vaultRelative := vaultRelativePath(opts.Mapping.VaultReviewPath, file.Relative) + if err := verifyDestinationPreimage(intent.Destinations, vaultDir, "vault", vaultRelative); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } + parentDir := filepath.ToSlash(filepath.Dir(vaultRelative)) + if parentDir != "." && parentDir != "" { + if err := vaultDir.EnsureDirectory(parentDir, 0o755); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } + } + if err := atomicfile.WriteRoot(vaultDir.Root, vaultRelative, file.Desired, file.Mode); err != nil { + return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("write Vault file %q: %w", vaultRelative, err)) + } + if err := runPublishCheckpoint(opts, checkpointAfterDestination, "vault", vaultRelative); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } + } + } + if err := j.Advance(StageProjectWritten, StageVaultSynced); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } + + // Verify every Project and Vault target before the pointer is committed. + projFiles, vaultFiles, err := verifyPublishedFiles(opts.Plan, opts.Mapping, projectDir, vaultDir) + if err != nil { + return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("verify published files: %w", err)) + } + + if err := j.Advance(StageVaultSynced, StageVerified); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } + + // Extract hashes for proof + var reviewSHA, historySHA, ledgerSHA, sessionIndexSHA string + for _, f := range projFiles { + switch f.Relative { + case reviewv2.ReviewRelativePath: + reviewSHA = f.SHA256 + case reviewv2.HistoryRelativePath: + historySHA = f.SHA256 + case reviewv2.MachineLedgerRelativePath: + ledgerSHA = f.SHA256 + case sessionIndexRelativePath: + sessionIndexSHA = f.SHA256 + } + } + + proof := PublicationProof{ + ProjectID: opts.ProjectID, + GenerationID: opts.PreparedGeneration, + ManifestDigest: prepared.ManifestDigest, + ProjectViewDigest: manifest.ProjectViewDigest, + ReviewSHA256: reviewSHA, + HistorySHA256: historySHA, + LedgerSHA256: ledgerSHA, + JournalVerified: true, + } + if projectionVersion == 4 { + proof.Version = 4 + proof.SessionIndexSHA256 = sessionIndexSHA + } + if err := runPublishCheckpoint(opts, checkpointBeforePointerCommit, "", ""); err != nil { + return Result{}, rollbackFailure(ctx, intent, err) + } + if err := store.CommitPublished(opts.PreparedGeneration, proof); err != nil { + return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("commit published generation: %w", err)) + } + if err := j.Advance(StageVerified, StageCommitted); err != nil { + return Result{}, err + } + + return Result{GenerationID: opts.PreparedGeneration, ProjectFiles: projFiles, VaultFiles: vaultFiles, Recovered: recovered}, nil +} + +func publishLegacyV3(ctx context.Context, opts Options, intent Intent, rollbackFailure func(context.Context, Intent, error) error, now func() time.Time) error { + // Ensure sync scaffold directories and lock. syncDataDir := filepath.Join(opts.DataRoot, "projects", opts.ProjectID) syncDataRoot, err := pathguard.Open(syncDataDir) if err != nil { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("open project sync data root: %w", err)) + return rollbackFailure(ctx, intent, fmt.Errorf("open project sync data root: %w", err)) } for _, name := range []string{"merge-bases", "queue", "transactions", "locks"} { if err := syncDataRoot.EnsureDirectory(name, 0o700); err != nil { closeErr := syncDataRoot.Close() - return Result{}, rollbackFailure(ctx, intent, errors.Join(fmt.Errorf("ensure sync directory %q: %w", name, err), closeErr)) + return rollbackFailure(ctx, intent, errors.Join(fmt.Errorf("ensure sync directory %q: %w", name, err), closeErr)) } } if err := syncDataRoot.Close(); err != nil { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("close project sync data root: %w", err)) + return rollbackFailure(ctx, intent, fmt.Errorf("close project sync data root: %w", err)) } trustTransition := func(relative string, preimageExists bool, preimageHash, targetHash string) (bool, error) { @@ -305,6 +408,7 @@ func Publish(ctx context.Context, opts Options) (Result, error) { Now: now, Trigger: "cli", RepairMachineLedger: true, + AllowV3Publication: true, TrustAppliedTransition: trustTransition, } preflightOpts := syncOpts @@ -314,10 +418,10 @@ func Publish(ctx context.Context, opts Options) (Result, error) { // that the actual repair remains exclusive to the real pass below. preflight, err := syncproject.Run(ctx, preflightOpts) if err != nil { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("sync to vault preflight: %w", err)) + return rollbackFailure(ctx, intent, fmt.Errorf("sync to vault preflight: %w", err)) } if !syncReportReadyToApply(preflight) { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf( + return rollbackFailure(ctx, intent, fmt.Errorf( "sync to vault preflight did not converge: conflicts=%d issues=%d errors=%d error_codes=%s queue_depth=%d derived=%s migration_required=%t machine=%s", len(preflight.Conflicts), len(preflight.Issues), len(preflight.Errors), syncErrorSummary(preflight.Errors), preflight.QueueDepth, preflight.Derived.State, preflight.Migration.Required, preflight.Machine.State, @@ -325,60 +429,16 @@ func Publish(ctx context.Context, opts Options) (Result, error) { } rep, err := syncproject.Run(ctx, syncOpts) if err != nil { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("sync to vault: %w", err)) + return rollbackFailure(ctx, intent, fmt.Errorf("sync to vault: %w", err)) } if !syncReportConverged(rep) { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf( + return rollbackFailure(ctx, intent, fmt.Errorf( "sync to vault did not converge: conflicts=%d issues=%d errors=%d error_codes=%s queue_depth=%d derived=%s migration_required=%t machine=%s", len(rep.Conflicts), len(rep.Issues), len(rep.Errors), syncErrorSummary(rep.Errors), rep.QueueDepth, rep.Derived.State, rep.Migration.Required, rep.Machine.State, )) } - if err := j.Advance(StageProjectWritten, StageVaultSynced); err != nil { - return Result{}, rollbackFailure(ctx, intent, err) - } - - // Verify all 3 Project files and 3 Vault files match schema 3 and desired hashes - projFiles, vaultFiles, err := verifyPublishedFiles(opts.Plan, opts.Mapping, projectDir, vaultDir) - if err != nil { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("verify published files: %w", err)) - } - - if err := j.Advance(StageVaultSynced, StageVerified); err != nil { - return Result{}, rollbackFailure(ctx, intent, err) - } - - // Extract hashes for proof - var reviewSHA, historySHA, ledgerSHA string - for _, f := range projFiles { - switch f.Relative { - case reviewv2.ReviewRelativePath: - reviewSHA = f.SHA256 - case reviewv2.HistoryRelativePath: - historySHA = f.SHA256 - case reviewv2.MachineLedgerRelativePath: - ledgerSHA = f.SHA256 - } - } - - proof := PublicationProof{ - ProjectID: opts.ProjectID, - GenerationID: opts.PreparedGeneration, - ManifestDigest: prepared.ManifestDigest, - ProjectViewDigest: manifest.ProjectViewDigest, - ReviewSHA256: reviewSHA, - HistorySHA256: historySHA, - LedgerSHA256: ledgerSHA, - JournalVerified: true, - } - if err := store.CommitPublished(opts.PreparedGeneration, proof); err != nil { - return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("commit published generation: %w", err)) - } - if err := j.Advance(StageVerified, StageCommitted); err != nil { - return Result{}, err - } - - return Result{GenerationID: opts.PreparedGeneration, ProjectFiles: projFiles, VaultFiles: vaultFiles, Recovered: recovered}, nil + return nil } func syncErrorSummary(entityErrors []syncengine.EntityError) string { @@ -392,6 +452,50 @@ func syncErrorSummary(entityErrors []syncengine.EntityError) string { return strings.Join(values, ",") } +func runPublishCheckpoint(opts Options, stage publishCheckpoint, side, relative string) error { + if opts.checkpoint == nil { + return nil + } + return opts.checkpoint(stage, side, relative) +} + +func planProjectionVersion(plan presentation.RenderPlan) (int, error) { + required := map[string]bool{ + reviewv2.ReviewRelativePath: false, + reviewv2.HistoryRelativePath: false, + reviewv2.MachineLedgerRelativePath: false, + } + hasIndex := false + seen := make(map[string]bool, len(plan.Files)) + for _, file := range plan.Files { + if file.Relative == "" || seen[file.Relative] { + return 0, errors.New("publication plan contains an empty or duplicate destination") + } + seen[file.Relative] = true + if _, ok := required[file.Relative]; ok { + required[file.Relative] = true + } + if file.Relative == sessionIndexRelativePath { + hasIndex = true + } + } + for relative, found := range required { + if !found { + return 0, fmt.Errorf("publication plan is missing required file %q", relative) + } + } + if hasIndex { + if len(plan.Files) != 4 { + return 0, errors.New("v4 publication plan must contain exactly four files") + } + return 4, nil + } + if len(plan.Files) != 3 { + return 0, errors.New("legacy v3 publication plan must contain exactly three files") + } + return 3, nil +} + func syncReportReadyToApply(report syncengine.Report) bool { return len(report.Conflicts) == 0 && len(report.Issues) == 0 && @@ -558,6 +662,13 @@ func rollbackIntent(ctx context.Context, intent Intent, j *Journal, projectDir, // Vault bytes must both equal the immutable journal preimage, while the current // Base must equal that same journal intent's desired bytes. func repairRolledBackBases(intent Intent, j *Journal, opts Options, projectDir, vaultDir *pathguard.Directory, now func() time.Time) error { + // Four-file v4 publication never uses the legacy sync merge-base store. + // Its generic Intent preimages are the complete recovery authority. + for _, destination := range intent.Destinations { + if destination.Side == "project" && destination.Relative == sessionIndexRelativePath { + return nil + } + } if now == nil { now = time.Now } @@ -659,16 +770,31 @@ func repairRolledBackBases(intent Intent, j *Journal, opts Options, projectDir, } func vaultRelativePath(vaultReviewPath, projectRelative string) string { - switch projectRelative { - case reviewv2.ReviewRelativePath: - return path.Join(vaultReviewPath, path.Base(reviewv2.ReviewRelativePath)) - case reviewv2.HistoryRelativePath: - return path.Join(vaultReviewPath, path.Base(reviewv2.HistoryRelativePath)) - case reviewv2.MachineLedgerRelativePath: - return path.Join(vaultReviewPath, ".session-reviewer/ledger.json") - default: - return path.Join(vaultReviewPath, projectRelative) + if relative, ok := strings.CutPrefix(projectRelative, "docs/session-review/"); ok { + return path.Join(vaultReviewPath, relative) + } + return path.Join(vaultReviewPath, projectRelative) +} + +func verifyDestinationPreimage(destinations []Destination, directory *pathguard.Directory, side, relative string) error { + for _, destination := range destinations { + if destination.Side != side || destination.Relative != relative { + continue + } + body, found, err := directory.ReadRegularOptional(relative, 64<<20) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + actual := "missing" + if found { + actual = sha256Hex(body) + } + if found != destination.PreimageExists || (found && !strings.EqualFold(actual, destination.PreimageSHA256)) { + return fmt.Errorf("%w: %w", ErrPublicationConflict, &PublicationConflictError{Side: side, Relative: relative, Expected: destination.PreimageSHA256, Actual: actual}) + } + return nil } + return errors.New("publication destination is missing from intent") } func sha256Hex(data []byte) string { diff --git a/internal/publication/service_test.go b/internal/publication/service_test.go index 1e4e735..8dfef4c 100644 --- a/internal/publication/service_test.go +++ b/internal/publication/service_test.go @@ -6,8 +6,10 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -312,6 +314,225 @@ func TestPublishCleanRunSucceeds(t *testing.T) { } } +func TestPublishFourFilePlanVerifiesSessionIndexBeforePointerCommit(t *testing.T) { + projectID := "project-four-file" + dataRoot, projectRoot, vaultRoot, mapping, manifest, plan := setupPublishEnv(t, projectID) + // A v4 review is JSON, not a syncdoc Markdown document. If the generic + // four-file path accidentally delegates to the legacy sync engine this + // publication fails before the Vault files can be verified. + plan.Files[0].Desired = []byte("{\"schema_version\":4}\n") + indexBody := []byte("{\"schema_version\":1}\n") + plan.Files = append(plan.Files, presentation.FilePlan{ + Relative: "docs/session-review/.session-reviewer/session-index.json", Desired: indexBody, Mode: 0o600, + }) + result, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }) + if err != nil { + t.Fatal(err) + } + if len(result.ProjectFiles) != 4 || len(result.VaultFiles) != 4 { + t.Fatalf("verified files = %d/%d", len(result.ProjectFiles), len(result.VaultFiles)) + } + for _, target := range []string{ + filepath.Join(projectRoot, filepath.FromSlash("docs/session-review/.session-reviewer/session-index.json")), + filepath.Join(vaultRoot, filepath.FromSlash(mapping.VaultReviewPath), ".session-reviewer", "session-index.json"), + } { + got, err := os.ReadFile(target) + if err != nil || !bytes.Equal(got, indexBody) { + t.Fatalf("session index at %s = %q, %v", target, got, err) + } + } + store, err := memorystore.Open(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if published, _, err := store.LoadPublished(); err != nil || published != manifest.GenerationID { + t.Fatalf("published pointer = %q, %v", published, err) + } +} + +// Moving the published-generation commit above complete destination +// verification makes this test expose a new generation while the public atom +// has already been rolled back to its old preimages. +func TestPublishFourFilePlanCommitsPointerLast(t *testing.T) { + projectID := "project-pointer-last" + dataRoot, projectRoot, vaultRoot, mapping, manifest, plan := setupPublishEnv(t, projectID) + for index := range plan.Files { + plan.Files[index].Desired = []byte(fmt.Sprintf("v4-target-%d\n", index)) + } + plan.Files = append(plan.Files, presentation.FilePlan{ + Relative: "docs/session-review/.session-reviewer/session-index.json", + Desired: []byte("v4-target-index\n"), Mode: 0o600, + }) + stop := errors.New("stop before published pointer") + opts := Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + checkpoint: func(stage publishCheckpoint, side, relative string) error { + if stage == checkpointBeforePointerCommit { + return stop + } + return nil + }, + } + if _, err := Publish(context.Background(), opts); !errors.Is(err, stop) { + t.Fatalf("Publish error = %v, want checkpoint error", err) + } + store, err := memorystore.Open(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, _, err := store.LoadPublished(); !errors.Is(err, memorystore.ErrNoPublishedGeneration) { + t.Fatalf("published pointer advanced before final checkpoint: %v", err) + } + for _, file := range plan.Files { + for _, target := range []string{ + filepath.Join(projectRoot, filepath.FromSlash(file.Relative)), + filepath.Join(vaultRoot, filepath.FromSlash(vaultRelativePath(mapping.VaultReviewPath, file.Relative))), + } { + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rolled-back target %s remains: %v", target, err) + } + } + } +} + +func TestPublishFourFilePlanRecoversGenericIntentAfterRestart(t *testing.T) { + projectID := "project-four-file-recovery" + dataRoot, projectRoot, vaultRoot, mapping, manifest, plan := setupPublishEnv(t, projectID) + plan.Files = append(plan.Files, presentation.FilePlan{ + Relative: sessionIndexRelativePath, Desired: []byte("v4-index-target\n"), Mode: 0o600, + }) + for index := range plan.Files { + old := []byte(fmt.Sprintf("old-four-file-%d\n", index)) + plan.Files[index].ExpectedExists = true + plan.Files[index].Expected = old + plan.Files[index].Desired = []byte(fmt.Sprintf("new-four-file-%d\n", index)) + for _, target := range []string{ + filepath.Join(projectRoot, filepath.FromSlash(plan.Files[index].Relative)), + filepath.Join(vaultRoot, filepath.FromSlash(vaultRelativePath(mapping.VaultReviewPath, plan.Files[index].Relative))), + } { + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, old, 0o600); err != nil { + t.Fatal(err) + } + } + } + + store, err := memorystore.Open(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + prepared, _, err := store.LoadPrepared() + if err != nil { + store.Close() + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + j, err := OpenJournal(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + destinations := make([]Destination, 0, len(plan.Files)*2) + for _, file := range plan.Files { + preimageSHA := sha256Hex(file.Expected) + if err := j.PutPreimage(preimageSHA, file.Expected); err != nil { + j.Close() + t.Fatal(err) + } + destinations = append(destinations, + Destination{Side: "project", Relative: file.Relative, PreimageSHA256: preimageSHA, DesiredSHA256: sha256Hex(file.Desired), PreimageExists: true}, + Destination{Side: "vault", Relative: vaultRelativePath(mapping.VaultReviewPath, file.Relative), PreimageSHA256: preimageSHA, DesiredSHA256: sha256Hex(file.Desired), PreimageExists: true}, + ) + } + sort.Slice(destinations, func(i, j int) bool { + if destinations[i].Side != destinations[j].Side { + return destinations[i].Side < destinations[j].Side + } + return destinations[i].Relative < destinations[j].Relative + }) + intent := Intent{ + Version: 1, ProjectID: projectID, GenerationID: manifest.GenerationID, + ManifestDigest: prepared.ManifestDigest, ProjectViewDigest: prepared.ProjectViewDigest, + Stage: StagePrepared, CreatedAt: time.Now().UTC(), Destinations: destinations, + } + if err := j.Create(intent); err != nil { + j.Close() + t.Fatal(err) + } + // Model a process exit after all Project writes and two Vault writes. The + // next Publish invocation must recover from the generic destination list; + // no legacy fixed three-file journal participates. + for _, file := range plan.Files { + if err := os.WriteFile(filepath.Join(projectRoot, filepath.FromSlash(file.Relative)), file.Desired, file.Mode); err != nil { + j.Close() + t.Fatal(err) + } + } + if err := j.Advance(StagePrepared, StageProjectWritten); err != nil { + j.Close() + t.Fatal(err) + } + for _, file := range plan.Files[:2] { + if err := os.WriteFile(filepath.Join(vaultRoot, filepath.FromSlash(vaultRelativePath(mapping.VaultReviewPath, file.Relative))), file.Desired, file.Mode); err != nil { + j.Close() + t.Fatal(err) + } + } + if err := j.Close(); err != nil { + t.Fatal(err) + } + + result, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }) + if err != nil { + t.Fatal(err) + } + if !result.Recovered || len(result.ProjectFiles) != 4 || len(result.VaultFiles) != 4 { + t.Fatalf("recovery result = %+v", result) + } + for _, file := range plan.Files { + for _, target := range []string{ + filepath.Join(projectRoot, filepath.FromSlash(file.Relative)), + filepath.Join(vaultRoot, filepath.FromSlash(vaultRelativePath(mapping.VaultReviewPath, file.Relative))), + } { + body, err := os.ReadFile(target) + if err != nil || !bytes.Equal(body, file.Desired) { + t.Fatalf("recovered target %s = %q, %v", target, body, err) + } + } + } + store, err = memorystore.Open(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + defer store.Close() + published, publishedManifest, err := store.LoadPublished() + if err != nil || published != manifest.GenerationID || publishedManifest.GenerationID != manifest.GenerationID { + t.Fatalf("published recovery = generation %q manifest %+v err %v", published, publishedManifest, err) + } + repeated, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }) + if err != nil { + t.Fatalf("repeat v4 publication: %v", err) + } + if fmt.Sprint(repeated.ProjectFiles) != fmt.Sprint(result.ProjectFiles) || fmt.Sprint(repeated.VaultFiles) != fmt.Sprint(result.VaultFiles) { + t.Fatalf("repeat v4 publication changed verified hashes: first=%+v/%+v second=%+v/%+v", result.ProjectFiles, result.VaultFiles, repeated.ProjectFiles, repeated.VaultFiles) + } +} + func TestPublishPreimageMismatchFailsClosed(t *testing.T) { projectID := "project-conflict" dataRoot, projectRoot, _, mapping, manifest, plan := setupPublishEnv(t, projectID) diff --git a/internal/reviewv2/v3_test.go b/internal/reviewv2/v3_test.go index 4746cd1..5cd187f 100644 --- a/internal/reviewv2/v3_test.go +++ b/internal/reviewv2/v3_test.go @@ -49,6 +49,32 @@ func TestV2WriterFailsClosedBeforeMutatingV3(t *testing.T) { assertV3TreeEqual(t, before, snapshotV3Tree(t, root)) } +func TestCompatibilityV2AndV3ReadersRemainDistinct(t *testing.T) { + v2Review, err := ParseReview(mustFixture(t, "../../testdata/review-v2/项目回顾.valid.md")) + if err != nil { + t.Fatal(err) + } + v2History, err := ParseHistory(mustFixture(t, "../../testdata/review-v2/项目历史.valid.md")) + if err != nil { + t.Fatal(err) + } + v2Ledger, err := ParseMachineLedger(mustFixture(t, "../../testdata/review-v2/ledger.valid.json")) + if err != nil { + t.Fatal(err) + } + if v2Review.Model.GenerationID != "" || v2History.GenerationID != "" || v2Ledger.SchemaVersion != 2 { + t.Fatalf("v2 reader identity changed: review_generation=%q history_generation=%q ledger_version=%d", v2Review.Model.GenerationID, v2History.GenerationID, v2Ledger.SchemaVersion) + } + + root := writeV3Fixture(t) + if _, err := LoadV3(root); err != nil { + t.Fatalf("strict v3 read failed: %v", err) + } + if _, err := Load(root); err == nil { + t.Fatal("v2 compatibility reader skipped directly over the v3 boundary") + } +} + func TestV3SupportedHumanEditAndUnknownBlockArePresentationInput(t *testing.T) { root := writeV3Fixture(t) reviewPath := filepath.Join(root, filepath.FromSlash(ReviewRelativePath)) diff --git a/internal/syncproject/migration.go b/internal/syncproject/migration.go new file mode 100644 index 0000000..0d3c0b4 --- /dev/null +++ b/internal/syncproject/migration.go @@ -0,0 +1,330 @@ +package syncproject + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "time" + + "github.com/neomei/SessionReviewer/internal/config" + "github.com/neomei/SessionReviewer/internal/memory" + "github.com/neomei/SessionReviewer/internal/memorystore" + "github.com/neomei/SessionReviewer/internal/migrationv4" + "github.com/neomei/SessionReviewer/internal/presentation" + "github.com/neomei/SessionReviewer/internal/project" + "github.com/neomei/SessionReviewer/internal/sessionindex" +) + +type MigrationMode string + +const ( + MigrationDryRun MigrationMode = "dry-run" + MigrationConfirm MigrationMode = "confirm-migration" +) + +var ( + ErrMigrationRequired = errors.New("migration_required") + ErrMigrationPreviewStale = errors.New("migration_preview_stale") +) + +type MigrationPublication struct { + ProjectID string + PreparedGeneration string + Plan presentation.RenderPlan + Mapping config.ProjectMapping + DataRoot string + Preview migrationv4.MigrationPreview + + syncDataRoot *os.Root +} + +type MigrationPublisher func(context.Context, MigrationPublication) error + +type MigrationOptions struct { + Options + Mode MigrationMode + ExpectedPreviewDigest string + Publish MigrationPublisher + + build func(*MappingPin) (migrationv4.Result, error) +} + +type MigrationResult struct { + Preview migrationv4.MigrationPreview `json:"preview"` + Applied bool `json:"applied"` +} + +// RunMigration owns the same project lock as ordinary reconciliation, rebuilds +// the complete migration preview inside that lock, and invokes one publisher +// for the four-file atom only after the expected digest still matches. +func RunMigration(ctx context.Context, options MigrationOptions) (_ MigrationResult, retErr error) { + if ctx == nil { + return MigrationResult{}, errors.New("migration context is required") + } + if options.Mode != MigrationDryRun && options.Mode != MigrationConfirm { + return MigrationResult{}, errors.New("invalid migration mode") + } + if options.Mode == MigrationConfirm && options.ExpectedPreviewDigest == "" { + return MigrationResult{}, errors.New("expected migration preview digest is required") + } + pin, err := PinMapping(options.Options) + if err != nil { + return MigrationResult{}, err + } + defer func() { retErr = errors.Join(retErr, pin.Close()) }() + lock, err := project.AcquireProjectLock(pin.syncData.Root, "locks/sync.lock", 10*time.Second) + if err != nil { + return MigrationResult{}, errors.New("sync project is locked or unsafe") + } + defer func() { retErr = errors.Join(retErr, lock.Release()) }() + if err := pin.verify(options.Options); err != nil { + return MigrationResult{}, err + } + build := options.build + if build == nil { + build = buildMigrationFromPin + } + plan, err := build(pin) + if err != nil { + return MigrationResult{}, fmt.Errorf("build v4 migration preview: %w", err) + } + result := MigrationResult{Preview: plan.Preview} + if options.Mode == MigrationDryRun { + return result, nil + } + if plan.Preview.PreviewDigest != options.ExpectedPreviewDigest { + return MigrationResult{}, ErrMigrationPreviewStale + } + if options.Publish == nil { + return MigrationResult{}, errors.New("migration publisher is required") + } + if err := pin.verify(options.Options); err != nil { + return MigrationResult{}, err + } + publication := MigrationPublication{ + ProjectID: pin.mapping.ID, PreparedGeneration: plan.Preview.GenerationID, + Plan: presentation.RenderPlan{ + ProjectID: pin.mapping.ID, GenerationID: plan.Preview.GenerationID, + ProjectViewDigest: plan.Accepted.Review.ProjectViewDigest, + Files: migrationFilePlan(plan), + }, + Mapping: pin.mapping, DataRoot: pin.data.Path, Preview: plan.Preview, + syncDataRoot: pin.syncData.Root, + } + if err := options.Publish(ctx, publication); err != nil { + return MigrationResult{}, err + } + if err := pin.verify(options.Options); err != nil { + return MigrationResult{}, err + } + result.Applied = true + return result, nil +} + +func migrationFilePlan(result migrationv4.Result) []presentation.FilePlan { + files := []presentation.FilePlan{ + {Relative: migrationv4.ReviewRelativePath, Desired: result.Review, Mode: 0o644}, + {Relative: migrationv4.HistoryRelativePath, Desired: result.History, Mode: 0o644}, + {Relative: migrationv4.LedgerRelativePath, Desired: result.Ledger, Mode: 0o600}, + {Relative: migrationv4.SessionIndexRelativePath, Desired: result.SessionIndex, Mode: 0o600}, + } + for index := range files { + preimage := result.TargetPreimages[files[index].Relative] + files[index].ExpectedExists = preimage.Exists + files[index].Expected = append([]byte(nil), preimage.Bytes...) + } + return files +} + +func buildMigrationFromPin(pin *MappingPin) (migrationv4.Result, error) { + preimages := make(map[string]migrationv4.Preimage, 4) + read := func(relative string, required bool) ([]byte, error) { + body, found, err := pin.project.ReadRegularOptional(relative, 64<<20) + if err != nil { + return nil, err + } + if required && !found { + return nil, fmt.Errorf("required v3 source %q is missing", relative) + } + preimages[relative] = migrationv4.Preimage{Exists: found, Bytes: append([]byte(nil), body...)} + return body, nil + } + review, err := read(migrationv4.ReviewRelativePath, true) + if err != nil { + return migrationv4.Result{}, err + } + history, err := read(migrationv4.HistoryRelativePath, true) + if err != nil { + return migrationv4.Result{}, err + } + ledger, err := read(migrationv4.LedgerRelativePath, true) + if err != nil { + return migrationv4.Result{}, err + } + if _, err := read(migrationv4.SessionIndexRelativePath, false); err != nil { + return migrationv4.Result{}, err + } else if preimages[migrationv4.SessionIndexRelativePath].Exists { + return migrationv4.Result{}, errors.New("partial v4 projection cannot be migrated") + } + + store, err := memorystore.Open(pin.data.Path, pin.mapping.ID) + if err != nil { + return migrationv4.Result{}, err + } + defer store.Close() + _, manifest, err := store.LoadPrepared() + if err != nil { + return migrationv4.Result{}, err + } + index, err := migrationSessionIndex(store, manifest) + if err != nil { + return migrationv4.Result{}, err + } + return migrationv4.BuildPreview(migrationv4.Input{ + Review: review, History: history, Ledger: ledger, SessionIndex: index, + GenerationID: manifest.GenerationID, SessionViewDependencyDigests: manifestSessionDigests(manifest), + TargetPreimages: preimages, + }) +} + +func migrationSessionIndex(store *memorystore.Store, manifest memory.GenerationManifest) ([]byte, error) { + entries := make([]sessionindex.Entry, 0, len(manifest.SessionViews)) + coverage := sessionindex.IndexCoverage{Total: uint64(len(manifest.SessionViews))} + for _, dependency := range manifest.SessionViews { + body, err := store.LoadObject(memorystore.ObjectSessionView, dependency.Digest) + if err != nil { + return nil, err + } + var view memory.SessionView + if err := json.Unmarshal(body, &view); err != nil { + return nil, err + } + entry, err := migrationIndexEntry(view, dependency, manifest.GenerationID) + if err != nil { + return nil, err + } + entries = append(entries, entry) + addIndexCoverage(&coverage, entry) + } + sort.Slice(entries, func(i, j int) bool { + if entries[i].StartedAt != entries[j].StartedAt { + return entries[i].StartedAt > entries[j].StartedAt + } + if entries[i].Provider != entries[j].Provider { + return entries[i].Provider < entries[j].Provider + } + return entries[i].SessionID < entries[j].SessionID + }) + return sessionindex.Render(sessionindex.Document{ + SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: manifest.ProjectID, + GenerationID: manifest.GenerationID, ProjectViewDigest: manifest.ProjectViewDigest, + GeneratedAt: manifest.CreatedAt, SortVersion: sessionindex.SortVersion, + Coverage: coverage, Sessions: entries, + }) +} + +func migrationIndexEntry(view memory.SessionView, dependency memory.SessionViewDependency, generationID string) (sessionindex.Entry, error) { + state, reason := sessionindex.ProcessingComplete, "" + availability := "available" + if view.SourceAvailability != memory.SourceAvailable { + availability = "unavailable" + } + switch view.TerminalState { + case memory.Indexed: + case memory.Unsupported: + state, reason = sessionindex.ProcessingError, "unsupported_source_records" + case memory.Missing: + state, reason = sessionindex.ProcessingUnprocessed, "source_missing" + case memory.Unreadable: + state, reason = sessionindex.ProcessingError, "source_unreadable" + case memory.Ambiguous: + state, reason = sessionindex.ProcessingError, "source_ambiguous" + default: + return sessionindex.Entry{}, errors.New("unsupported SessionView terminal state") + } + reasons := []string{} + if reason != "" { + reasons = append(reasons, reason) + } + terminal := string(view.TerminalState) + duration := migrationDuration(view.StartedAt, view.EndedAt) + recordCount := uint64(len(view.ActiveRevisionIDs)) + sessionDigest := dependency.Digest + usageDigest := view.UsageRecordDigest + lastSeen := generationID + var lastSuccessful *string + if state == sessionindex.ProcessingComplete { + value := generationID + lastSuccessful = &value + } + entryCoverage := sessionindex.Coverage{Seen: uint64(len(view.ObservationSummaries)), Indexed: uint64(len(view.ObservationSummaries))} + facts := sessionindex.FactCounts{} + for _, observation := range view.ObservationSummaries { + switch observation.Kind { + case "file": + facts.FileChange++ + case "command", "tool": + facts.Command++ + case "test", "verification", "build": + facts.Verification++ + case "error": + facts.Error++ + case "artifact", "commit", "release", "deployment": + facts.Artifact++ + } + } + return sessionindex.Entry{ + Provider: view.Provider, SessionID: view.SessionID, ProcessingState: state, + StateReasonCodes: reasons, SourceAvailability: availability, SourceTerminalState: &terminal, + StartedAt: view.StartedAt, EndedAt: view.EndedAt, DurationMS: duration, + WarningCount: uint64(len(view.Diagnostics)), RecordCount: &recordCount, + IndexedEventCount: entryCoverage.Indexed, Coverage: entryCoverage, FactCounts: facts, + SessionViewDigest: &sessionDigest, UsageRecordDigest: &usageDigest, + SummaryDigest: nil, LastSeenGenerationID: &lastSeen, LastSuccessfulGenerationID: lastSuccessful, + }, nil +} + +func migrationDuration(startedAt, endedAt string) *uint64 { + start, startErr := time.Parse(time.RFC3339Nano, startedAt) + end, endErr := time.Parse(time.RFC3339Nano, endedAt) + if startErr != nil || endErr != nil || end.Before(start) { + return nil + } + value := uint64(end.Sub(start) / time.Millisecond) + return &value +} + +func addIndexCoverage(coverage *sessionindex.IndexCoverage, entry sessionindex.Entry) { + switch entry.ProcessingState { + case sessionindex.ProcessingComplete: + coverage.Complete++ + case sessionindex.ProcessingPartial: + coverage.Partial++ + case sessionindex.ProcessingError: + coverage.Error++ + case sessionindex.ProcessingUnprocessed: + coverage.Unprocessed++ + } + if entry.SourceAvailability == "available" { + coverage.SourceAvailable++ + } else { + coverage.SourceUnavailable++ + } + coverage.StartedAtKnown++ + coverage.EndedAtKnown++ + if entry.UsageRecordDigest != nil { + coverage.UsageKnown++ + } +} + +func manifestSessionDigests(manifest memory.GenerationManifest) []string { + result := make([]string, 0, len(manifest.SessionViews)) + for _, dependency := range manifest.SessionViews { + result = append(result, dependency.Digest) + } + sort.Strings(result) + return result +} diff --git a/internal/syncproject/service.go b/internal/syncproject/service.go index 4439eb3..5f99f5b 100644 --- a/internal/syncproject/service.go +++ b/internal/syncproject/service.go @@ -13,6 +13,7 @@ import ( "github.com/neomei/SessionReviewer/internal/config" "github.com/neomei/SessionReviewer/internal/pathguard" + "github.com/neomei/SessionReviewer/internal/reviewv2" syncengine "github.com/neomei/SessionReviewer/internal/sync" ) @@ -33,7 +34,11 @@ type Options struct { // whose accepted apply is the legitimate writer that advanced the project // copy since the last successful sync; interactive sync keeps failing // closed so an out-of-band vault edit still requires an explicit repair. - RepairMachineLedger bool + RepairMachineLedger bool + // AllowV3Publication is reserved for the existing publication service, + // which must finish the byte-compatible v3 three-file transaction. Ordinary + // sync callers must use the explicit v3-to-v4 migration flow. + AllowV3Publication bool TrustAppliedTransition func(relative string, preimageExists bool, preimageHash, targetHash string) (bool, error) pinCheckpoint func(pinCheckpointStage) error @@ -68,6 +73,13 @@ func Run(ctx context.Context, options Options) (syncengine.Report, error) { if err := pin.verify(options); err != nil { return syncengine.Report{}, err } + version, err := reviewv2.DetectVersionExpected(pin.project.Path, pin.project.Info()) + if err != nil { + return syncengine.Report{}, err + } + if version == reviewv2.VersionV3 && !options.AllowV3Publication { + return syncengine.Report{}, ErrMigrationRequired + } if options.beforeEngine != nil { if err := options.beforeEngine(); err != nil { return syncengine.Report{}, err diff --git a/internal/syncproject/service_test.go b/internal/syncproject/service_test.go index ad4fe2a..ea1f4ef 100644 --- a/internal/syncproject/service_test.go +++ b/internal/syncproject/service_test.go @@ -2,21 +2,283 @@ package syncproject import ( "bytes" + "context" + "crypto/sha256" "errors" + "fmt" "os" "path/filepath" + "reflect" "runtime" + "strings" "testing" "time" "github.com/neomei/SessionReviewer/internal/config" "github.com/neomei/SessionReviewer/internal/ledger" + "github.com/neomei/SessionReviewer/internal/memory" + "github.com/neomei/SessionReviewer/internal/memorystore" + "github.com/neomei/SessionReviewer/internal/migrationv4" "github.com/neomei/SessionReviewer/internal/platform" "github.com/neomei/SessionReviewer/internal/project" "github.com/neomei/SessionReviewer/internal/reviewv2" syncengine "github.com/neomei/SessionReviewer/internal/sync" ) +func TestSyncProjectMigrationConfirmationRecomputesUnderProjectLock(t *testing.T) { + fixture := newMigrationServiceFixture(t) + preview := migrationv4.MigrationPreview{PreviewDigest: "sha256:" + strings.Repeat("1", 64)} + buildCalls := 0 + publishCalls := 0 + options := MigrationOptions{ + Options: Options{ProjectID: fixture.projectID, CWD: fixture.project, DataDir: fixture.data, GOOS: runtime.GOOS, Now: time.Now, Trigger: syncengine.TriggerCLI}, + Mode: MigrationDryRun, + build: func(*MappingPin) (migrationv4.Result, error) { + buildCalls++ + return migrationv4.Result{Preview: preview}, nil + }, + Publish: func(_ context.Context, publication MigrationPublication) error { + publishCalls++ + if publication.Preview.PreviewDigest != preview.PreviewDigest { + t.Fatalf("publication preview = %+v", publication.Preview) + } + lock, err := project.AcquireProjectLock(publication.syncDataRoot, "locks/sync.lock", 0) + if lock != nil { + _ = lock.Release() + } + if !errors.Is(err, project.ErrProjectLocked) { + t.Fatalf("publisher ran without project lock: %v", err) + } + return nil + }, + } + dry, err := RunMigration(t.Context(), options) + if err != nil || dry.Applied || dry.Preview.PreviewDigest != preview.PreviewDigest || buildCalls != 1 || publishCalls != 0 { + t.Fatalf("dry=%+v build=%d publish=%d err=%v", dry, buildCalls, publishCalls, err) + } + + options.Mode = MigrationConfirm + options.ExpectedPreviewDigest = preview.PreviewDigest + confirmed, err := RunMigration(t.Context(), options) + if err != nil || !confirmed.Applied || buildCalls != 2 || publishCalls != 1 { + t.Fatalf("confirmed=%+v build=%d publish=%d err=%v", confirmed, buildCalls, publishCalls, err) + } +} + +func TestSyncProjectMigrationConfirmationRejectsRecomputedStaleDigest(t *testing.T) { + fixture := newMigrationServiceFixture(t) + want := "sha256:" + strings.Repeat("1", 64) + options := MigrationOptions{ + Options: Options{ProjectID: fixture.projectID, CWD: fixture.project, DataDir: fixture.data, GOOS: runtime.GOOS, Now: time.Now, Trigger: syncengine.TriggerCLI}, + Mode: MigrationConfirm, ExpectedPreviewDigest: want, + build: func(*MappingPin) (migrationv4.Result, error) { + return migrationv4.Result{Preview: migrationv4.MigrationPreview{PreviewDigest: "sha256:" + strings.Repeat("2", 64)}}, nil + }, + Publish: func(context.Context, MigrationPublication) error { + t.Fatal("stale preview reached publisher") + return nil + }, + } + if _, err := RunMigration(t.Context(), options); !errors.Is(err, ErrMigrationPreviewStale) { + t.Fatalf("RunMigration error = %v", err) + } +} + +func TestSyncProjectPlainV3RequiresExplicitMigration(t *testing.T) { + fixture := newMigrationServiceFixture(t) + for relative, body := range map[string][]byte{ + reviewv2.ReviewRelativePath: []byte("---\nid: project-overview\nentity_type: project_review\nproject_id: project-migration\nschema_version: 3\nrevision: 1\n---\n# v3\n"), + reviewv2.HistoryRelativePath: []byte("---\nid: project-history\nentity_type: project_history\nproject_id: project-migration\nschema_version: 3\nrevision: 1\n---\n# history\n"), + reviewv2.MachineLedgerRelativePath: []byte("{}\n"), + } { + path := filepath.Join(fixture.project, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + } + _, err := Run(t.Context(), Options{ + ProjectID: fixture.projectID, CWD: fixture.project, DataDir: fixture.data, + GOOS: runtime.GOOS, Now: time.Now, Trigger: syncengine.TriggerCLI, + }) + if !errors.Is(err, ErrMigrationRequired) { + t.Fatalf("plain v3 sync error = %v", err) + } +} + +func TestSyncProjectBuildsBoundMigrationFromPreparedGeneration(t *testing.T) { + fixture := newMigrationServiceFixture(t) + manifest := seedMigrationPreparedGeneration(t, fixture) + before := snapshotMigrationPublicFiles(t, fixture) + dry, err := RunMigration(t.Context(), MigrationOptions{ + Options: Options{ProjectID: fixture.projectID, CWD: fixture.project, DataDir: fixture.data, GOOS: runtime.GOOS, Now: time.Now, Trigger: syncengine.TriggerCLI}, + Mode: MigrationDryRun, + }) + if err != nil { + t.Fatal(err) + } + if dry.Applied || dry.Preview.ProjectID != fixture.projectID || dry.Preview.GenerationID != manifest.GenerationID || dry.Preview.TargetPreimageHashes.SessionIndex != migrationv4.AbsentPreimageSHA256 { + t.Fatalf("dry migration = %+v", dry) + } + if after := snapshotMigrationPublicFiles(t, fixture); !reflect.DeepEqual(before, after) { + t.Fatalf("dry-run wrote public files: before=%v after=%v", before, after) + } + + published := 0 + confirmed, err := RunMigration(t.Context(), MigrationOptions{ + Options: Options{ProjectID: fixture.projectID, CWD: fixture.project, DataDir: fixture.data, GOOS: runtime.GOOS, Now: time.Now, Trigger: syncengine.TriggerCLI}, + Mode: MigrationConfirm, ExpectedPreviewDigest: dry.Preview.PreviewDigest, + Publish: func(_ context.Context, publication MigrationPublication) error { + published++ + if len(publication.Plan.Files) != 4 { + t.Fatalf("publication files = %d", len(publication.Plan.Files)) + } + for _, file := range publication.Plan.Files { + if file.Relative == migrationv4.SessionIndexRelativePath { + if file.ExpectedExists { + t.Fatal("new session index unexpectedly had a preimage") + } + } else if !file.ExpectedExists || len(file.Expected) == 0 { + t.Fatalf("source preimage missing for %s", file.Relative) + } + } + return nil + }, + }) + if err != nil || !confirmed.Applied || published != 1 || confirmed.Preview.PreviewDigest != dry.Preview.PreviewDigest { + t.Fatalf("confirmed=%+v published=%d err=%v", confirmed, published, err) + } +} + +type migrationServiceFixture struct { + projectID string + project string + data string +} + +func seedMigrationPreparedGeneration(t *testing.T, fixture migrationServiceFixture) memory.GenerationManifest { + t.Helper() + store, err := memorystore.Open(fixture.data, fixture.projectID) + if err != nil { + t.Fatal(err) + } + defer store.Close() + created := "2026-09-04T08:00:00Z" + probe := memory.ProjectProbeState{ + SchemaVersion: memory.MemorySchemaVersion, ProjectID: fixture.projectID, + CanonicalRoot: fixture.project, Branch: "main", Head: strings.Repeat("a", 40), + RemoteIdentityHashes: []string{}, VersionFiles: []memory.ProbeFile{}, RequiredProjectionFiles: []memory.ProbeFile{}, + ProbeVersion: "v1", Diagnostics: []memory.Diagnostic{}, + } + probe.Digest, err = memory.ProjectProbeStateDigest(probe) + if err != nil { + t.Fatal(err) + } + if _, err := store.PutProbeState(probe); err != nil { + t.Fatal(err) + } + view := memory.ProjectView{ + SchemaVersion: memory.MemorySchemaVersion, ProjectID: fixture.projectID, Generation: 1, + StartedAt: created, EndedAt: created, SourceSessions: 0, TerminalCounts: memory.TerminalCounts{}, + SessionViewDependencies: []memory.SessionViewDependency{}, ObservationRevisionIDs: []string{}, ProbeStateDigest: probe.Digest, + LiveState: memory.StateSnapshot{Branch: "main", Head: probe.Head}, WitnessedState: []memory.DerivedRecord{}, DerivedRecords: []memory.DerivedRecord{}, + AggregationCoverage: memory.ProjectAggregationCoverage{}, AssociatedUsage: []memory.AssociatedUsage{}, + DependencyDigest: "sha256:" + strings.Repeat("b", 64), ReducerVersion: "v1", + } + view.Digest, err = memory.ProjectViewDigest(view) + if err != nil { + t.Fatal(err) + } + if _, err := store.PutProjectView(view); err != nil { + t.Fatal(err) + } + manifest := memory.GenerationManifest{ + SchemaVersion: memory.MemorySchemaVersion, GenerationID: "generation-migration", ProjectID: fixture.projectID, CreatedAt: created, + SourceRecordDigests: []string{}, SessionViews: []memory.SessionViewDependency{}, SessionLineages: []memory.SessionLineageDependency{}, + ProbeStateDigest: probe.Digest, + ProbeCheck: memory.ProbeCheck{SchemaVersion: memory.MemorySchemaVersion, CheckedAt: created, StateDigest: probe.Digest, Available: true, Diagnostics: []memory.Diagnostic{}}, + ProjectViewDigest: view.Digest, + } + if _, err := store.PrepareGeneration(manifest); err != nil { + t.Fatal(err) + } + reviewModel := reviewv2.Review{ + ProjectID: fixture.projectID, GenerationID: manifest.GenerationID, MinimumWriterVersion: reviewv2.MinimumWriterVersion, + Revision: 1, Name: "Migration", Goal: "Preserve", Stage: "implementation", Status: "active", NextAction: "confirm", LastVerification: "2026-09-04", + Risks: []reviewv2.Risk{}, Decisions: []reviewv2.Decision{{ID: "decision-1", OccurredAt: "2026-09-04", Title: "Keep", Rationale: "because", Impact: "scope", Status: "active"}}, + } + reviewBody, err := reviewv2.RenderReviewV3(reviewModel) + if err != nil { + t.Fatal(err) + } + historyBody, err := reviewv2.RenderHistoryV3(fixture.projectID, 1, manifest.GenerationID, []reviewv2.Event{}) + if err != nil { + t.Fatal(err) + } + ledgerBody, err := reviewv2.RenderMachineLedgerV3(reviewv2.MachineLedgerV3{ + SchemaVersion: 3, MinimumWriterVersion: reviewv2.MinimumWriterVersion, + ProjectID: fixture.projectID, GenerationID: manifest.GenerationID, ProjectViewDigest: strings.TrimPrefix(view.Digest, "sha256:"), + AcceptedRevision: 1, ReviewSHA256: fmt.Sprintf("%x", sha256.Sum256(reviewBody)), HistorySHA256: fmt.Sprintf("%x", sha256.Sum256(historyBody)), + Sessions: []ledger.SessionReport{}, HumanPatches: []reviewv2.HumanPatchWire{}, OrphanPatches: []reviewv2.HumanPatchWire{}, GeneratedBaselines: []reviewv2.GeneratedBaselineWire{}, + LegacyCompatibility: reviewv2.LegacyCompatibility{Timeline: []ledger.TimelineEvent{}, Decisions: []ledger.Decision{}, OpenLoops: []ledger.OpenLoop{}, CurrentRisks: []reviewv2.CurrentRiskProvenance{}}, + }) + if err != nil { + t.Fatal(err) + } + for relative, body := range map[string][]byte{reviewv2.ReviewRelativePath: reviewBody, reviewv2.HistoryRelativePath: historyBody, reviewv2.MachineLedgerRelativePath: ledgerBody} { + path := filepath.Join(fixture.project, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, body, 0o600); err != nil { + t.Fatal(err) + } + } + return manifest +} + +func snapshotMigrationPublicFiles(t *testing.T, fixture migrationServiceFixture) map[string][]byte { + t.Helper() + result := map[string][]byte{} + for _, relative := range []string{migrationv4.ReviewRelativePath, migrationv4.HistoryRelativePath, migrationv4.LedgerRelativePath, migrationv4.SessionIndexRelativePath} { + path := filepath.Join(fixture.project, filepath.FromSlash(relative)) + body, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + t.Fatal(err) + } + result[relative] = body + } + return result +} + +func newMigrationServiceFixture(t *testing.T) migrationServiceFixture { + t.Helper() + root := t.TempDir() + projectRoot := filepath.Join(root, "project") + vaultRoot := filepath.Join(root, "vault") + dataRoot := filepath.Join(root, "data") + for _, directory := range []string{projectRoot, vaultRoot, filepath.Join(dataRoot, "projects", "project-migration", "locks")} { + if err := os.MkdirAll(directory, 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dataRoot, "projects", "project-migration", "locks", "sync.lock"), nil, 0o600); err != nil { + t.Fatal(err) + } + if err := config.Save(filepath.Join(dataRoot, "config.toml"), config.Config{Version: 1, Projects: []config.ProjectMapping{{ + ID: "project-migration", Root: projectRoot, VaultRoot: vaultRoot, + VaultReviewPath: "Projects/Migration/Session Review", VaultCaseMode: platform.CaseSensitive, + }}}); err != nil { + t.Fatal(err) + } + return migrationServiceFixture{projectID: "project-migration", project: projectRoot, data: dataRoot} +} + // Removing configured-mapping authentication, passing the global data root to // the engine, or reconciling with a different trigger makes this test fail. func TestSyncProjectServiceAuthenticatesMappingAndReconciles(t *testing.T) { diff --git a/testdata/contracts/migration/mixed.json b/testdata/contracts/migration/mixed.json new file mode 100644 index 0000000..140b998 --- /dev/null +++ b/testdata/contracts/migration/mixed.json @@ -0,0 +1,8 @@ +{ + "case": "mixed", + "review_version": 3, + "history_version": 3, + "ledger_version": 4, + "index_version": 1, + "expected": "reject" +} diff --git a/testdata/contracts/migration/newer.json b/testdata/contracts/migration/newer.json new file mode 100644 index 0000000..68f72c5 --- /dev/null +++ b/testdata/contracts/migration/newer.json @@ -0,0 +1,5 @@ +{ + "case": "newer", + "source_version": 5, + "expected": "reject" +} diff --git a/testdata/contracts/migration/partial.json b/testdata/contracts/migration/partial.json new file mode 100644 index 0000000..3017b9f --- /dev/null +++ b/testdata/contracts/migration/partial.json @@ -0,0 +1,6 @@ +{ + "case": "partial", + "present": ["review", "history", "ledger"], + "missing": "session_index", + "expected": "reject" +} diff --git a/testdata/contracts/migration/v2.json b/testdata/contracts/migration/v2.json new file mode 100644 index 0000000..df0f1cb --- /dev/null +++ b/testdata/contracts/migration/v2.json @@ -0,0 +1,6 @@ +{ + "case": "v2", + "source_version": 2, + "expected_reader": "reviewv2", + "expected_route": "migrationv3" +} diff --git a/testdata/contracts/migration/v3.json b/testdata/contracts/migration/v3.json new file mode 100644 index 0000000..75690c8 --- /dev/null +++ b/testdata/contracts/migration/v3.json @@ -0,0 +1,7 @@ +{ + "case": "v3", + "source_version": 3, + "target_version": 4, + "ordinary_sync_error": "migration_required", + "confirmation_required": true +} diff --git a/testdata/contracts/migration/v4.json b/testdata/contracts/migration/v4.json new file mode 100644 index 0000000..388f7c6 --- /dev/null +++ b/testdata/contracts/migration/v4.json @@ -0,0 +1,7 @@ +{ + "case": "v4", + "review_version": 4, + "ledger_version": 4, + "index_version": 1, + "expected_reader": "reviewv4.LoadProjection" +} From 34c3143b291cea5be022fc487f78c70cf53a9080 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 18:41:15 +0800 Subject: [PATCH 10/25] fix: close v4 migration review gaps --- internal/cli/sync.go | 4 +- internal/migrationv3/plan_test.go | 30 +- internal/migrationv4/migrate_test.go | 89 +++-- internal/migrationv4/plan.go | 8 +- internal/publication/service.go | 161 +++++++- internal/publication/service_test.go | 354 ++++++++++++++++-- internal/publicationlock/lock.go | 95 +++++ internal/syncproject/migration.go | 10 +- internal/syncproject/service_test.go | 20 +- .../.session-reviewer/ledger.json | 1 + .../.session-reviewer/session-index.json | 1 + ...71\347\233\256\345\216\206\345\217\262.md" | 12 + ...71\347\233\256\345\233\236\351\241\276.md" | 32 ++ .../.session-reviewer/ledger.json | 1 + .../.session-reviewer/session-index.json | 1 + ...71\347\233\256\345\216\206\345\217\262.md" | 12 + ...71\347\233\256\345\233\236\351\241\276.md" | 1 + .../.session-reviewer/ledger.json | 1 + ...71\347\233\256\345\216\206\345\217\262.md" | 12 + ...71\347\233\256\345\233\236\351\241\276.md" | 1 + .../.session-reviewer/ledger.json | 36 ++ ...71\347\233\256\345\216\206\345\217\262.md" | 10 + ...71\347\233\256\345\233\236\351\241\276.md" | 30 ++ .../.session-reviewer/ledger.json | 41 ++ ...71\347\233\256\345\216\206\345\217\262.md" | 12 + ...71\347\233\256\345\233\236\351\241\276.md" | 32 ++ .../.session-reviewer/ledger.json | 1 + .../.session-reviewer/session-index.json | 1 + ...71\347\233\256\345\216\206\345\217\262.md" | 12 + ...71\347\233\256\345\233\236\351\241\276.md" | 1 + 30 files changed, 943 insertions(+), 79 deletions(-) create mode 100644 internal/publicationlock/lock.go create mode 100644 testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/ledger.json create mode 100644 testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/session-index.json create mode 100644 "testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" create mode 100644 "testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" create mode 100644 testdata/contracts/migration/newer/docs/session-review/.session-reviewer/ledger.json create mode 100644 testdata/contracts/migration/newer/docs/session-review/.session-reviewer/session-index.json create mode 100644 "testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" create mode 100644 "testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" create mode 100644 testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json create mode 100644 "testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" create mode 100644 "testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" create mode 100644 testdata/contracts/migration/v2/docs/session-review/.session-reviewer/ledger.json create mode 100644 "testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" create mode 100644 "testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" create mode 100644 testdata/contracts/migration/v3/docs/session-review/.session-reviewer/ledger.json create mode 100644 "testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" create mode 100644 "testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" create mode 100644 testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json create mode 100644 testdata/contracts/migration/v4/docs/session-review/.session-reviewer/session-index.json create mode 100644 "testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" create mode 100644 "testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" diff --git a/internal/cli/sync.go b/internal/cli/sync.go index d0d1dec..468491e 100644 --- a/internal/cli/sync.go +++ b/internal/cli/sync.go @@ -235,11 +235,11 @@ func runSyncMigration(args []string, stdout, stderr io.Writer) int { func defaultSyncMigrationProject(ctx context.Context, options syncproject.MigrationOptions) (syncproject.MigrationResult, error) { options.Publish = func(ctx context.Context, plan syncproject.MigrationPublication) error { - _, err := publication.Publish(ctx, publication.Options{ + _, err := publication.PublishLocked(ctx, publication.Options{ ProjectID: plan.ProjectID, PreparedGeneration: plan.PreparedGeneration, Plan: plan.Plan, Mapping: plan.Mapping, DataRoot: plan.DataRoot, Now: options.Now, - }) + }, plan.PublicationLock) return err } return syncproject.RunMigration(ctx, options) diff --git a/internal/migrationv3/plan_test.go b/internal/migrationv3/plan_test.go index 1f79b70..cadf1b0 100644 --- a/internal/migrationv3/plan_test.go +++ b/internal/migrationv3/plan_test.go @@ -2,6 +2,8 @@ package migrationv3 import ( "context" + "os" + "path/filepath" "testing" "github.com/neomei/SessionReviewer/internal/reviewv2" @@ -40,20 +42,34 @@ func TestMigrationV3PlanDeterministic(t *testing.T) { } func TestCompatibilityV2StillUsesMigrationV3Plan(t *testing.T) { + root := t.TempDir() + for _, relative := range []string{reviewv2.ReviewRelativePath, reviewv2.HistoryRelativePath, reviewv2.MachineLedgerRelativePath} { + body, err := os.ReadFile(filepath.Join("../../testdata/contracts/migration/v2", relative)) + if err != nil { + t.Fatal(err) + } + destination := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, body, 0o600); err != nil { + t.Fatal(err) + } + } + accepted, err := reviewv2.Load(root) + if err != nil { + t.Fatalf("load complete v2 artifact set: %v", err) + } in := Input{ - ProjectID: "project-v2-compatibility", + ProjectID: accepted.State.Review.ProjectID, PreparedGeneration: "generation-v3-target", - AcceptedV2: reviewv2.Accepted{State: reviewv2.State{Review: reviewv2.Review{ - ProjectID: "project-v2-compatibility", - Revision: 2, - Decisions: []reviewv2.Decision{{ID: "decision-v2", Title: "Preserve v2 route", Status: "active"}}, - }}}, + AcceptedV2: accepted, } plan, err := BuildPlan(context.Background(), in) if err != nil { t.Fatal(err) } - if plan.SourceRevision != 2 || plan.PreparedGeneration != "generation-v3-target" || len(plan.LegacyItems) != 1 || plan.LegacyItems[0].EntityID != "decision-v2" { + if plan.SourceRevision != 2 || plan.PreparedGeneration != "generation-v3-target" || len(plan.LegacyItems) != 0 { t.Fatalf("legacy v2/v3 compatibility plan changed: %+v", plan) } } diff --git a/internal/migrationv4/migrate_test.go b/internal/migrationv4/migrate_test.go index 2e02e4e..1105c10 100644 --- a/internal/migrationv4/migrate_test.go +++ b/internal/migrationv4/migrate_test.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "strings" "testing" @@ -34,33 +35,18 @@ type compatibilityFixture struct { Expected string `json:"expected"` } -func TestCompatibilityMatrixFixturesExerciseRealReaders(t *testing.T) { +func TestCompatibilityMatrixFixturesExerciseProductionReaders(t *testing.T) { fixtures := loadCompatibilityFixtures(t) if got := fixtures["v2"]; got.SourceVersion != 2 || got.ExpectedReader != "reviewv2" || got.ExpectedRoute != "migrationv3" { t.Fatalf("unexpected v2 fixture: %+v", got) } - for _, path := range []string{"../../testdata/review-v2/项目回顾.valid.md", "../../testdata/review-v2/项目历史.valid.md", "../../testdata/review-v2/ledger.valid.json"} { - body, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - switch filepath.Base(path) { - case "项目回顾.valid.md": - if _, err := reviewv2.ParseReview(body); err != nil { - t.Fatalf("v2 review fixture is no longer readable: %v", err) - } - case "项目历史.valid.md": - if _, err := reviewv2.ParseHistory(body); err != nil { - t.Fatalf("v2 history fixture is no longer readable: %v", err) - } - default: - if _, err := reviewv2.ParseMachineLedger(body); err != nil { - t.Fatalf("v2 ledger fixture is no longer readable: %v", err) - } - } + v2Root := materializeCompatibilityFixture(t, "v2", false) + if _, err := reviewv2.Load(v2Root); err != nil { + t.Fatalf("complete v2 artifact set is no longer readable: %v", err) } - review, history, machine, index := migrationFixture(t) + review, history, machine := compatibilityArtifact(t, "v3", reviewv2.ReviewRelativePath), compatibilityArtifact(t, "v3", reviewv2.HistoryRelativePath), compatibilityArtifact(t, "v3", reviewv2.MachineLedgerRelativePath) + index := compatibilityArtifact(t, "v4", SessionIndexRelativePath) v3 := fixtures["v3"] if v3.SourceVersion != 3 || v3.TargetVersion != 4 || v3.OrdinarySyncError != "migration_required" || !v3.ConfirmationRequired { t.Fatalf("unexpected v3 fixture: %+v", v3) @@ -76,16 +62,19 @@ func TestCompatibilityMatrixFixturesExerciseRealReaders(t *testing.T) { if v4.ReviewVersion != 4 || v4.LedgerVersion != 4 || v4.IndexVersion != 1 || v4.ExpectedReader != "reviewv4.LoadProjection" { t.Fatalf("unexpected v4 fixture: %+v", v4) } - if _, err := reviewv4.LoadProjection(result.Review, result.History, result.Ledger, result.SessionIndex); err != nil { + v4Review, v4History, v4Ledger := compatibilityArtifact(t, "v4", ReviewRelativePath), compatibilityArtifact(t, "v4", HistoryRelativePath), compatibilityArtifact(t, "v4", LedgerRelativePath) + if _, err := reviewv4.LoadProjection(v4Review, v4History, v4Ledger, index); err != nil { t.Fatalf("v4 direct open failed: %v", err) } + if _, err := reviewv4.LoadProjection(result.Review, result.History, result.Ledger, result.SessionIndex); err != nil { + t.Fatalf("production migration result is not readable as v4: %v", err) + } newer := fixtures["newer"] - newerReview := bytes.Replace(review, []byte("schema_version: 3"), []byte(fmt.Sprintf("schema_version: %d", newer.SourceVersion)), 1) if newer.Expected != "reject" || newer.SourceVersion <= 4 { t.Fatalf("unexpected newer fixture: %+v", newer) } - if _, err := PreviewMigration(newerReview, history, machine); err == nil { + if _, err := reviewv4.LoadProjection(compatibilityArtifact(t, "newer", ReviewRelativePath), compatibilityArtifact(t, "newer", HistoryRelativePath), compatibilityArtifact(t, "newer", LedgerRelativePath), compatibilityArtifact(t, "newer", SessionIndexRelativePath)); err == nil { t.Fatal("newer fixture was accepted") } @@ -93,20 +82,47 @@ func TestCompatibilityMatrixFixturesExerciseRealReaders(t *testing.T) { if partial.Expected != "reject" || partial.Missing != "session_index" || len(partial.Present) != 3 { t.Fatalf("unexpected partial fixture: %+v", partial) } - if _, err := MigrateAcceptedV3(review, history, machine, nil); err == nil { + if _, err := reviewv4.LoadProjection(compatibilityArtifact(t, "partial", ReviewRelativePath), compatibilityArtifact(t, "partial", HistoryRelativePath), compatibilityArtifact(t, "partial", LedgerRelativePath), nil); err == nil { t.Fatal("partial fixture was accepted") } mixed := fixtures["mixed"] - mixedLedger := bytes.Replace(machine, []byte(`"schema_version": 3`), []byte(fmt.Sprintf(`"schema_version": %d`, mixed.LedgerVersion)), 1) if mixed.Expected != "reject" || mixed.ReviewVersion != 3 || mixed.HistoryVersion != 3 || mixed.LedgerVersion != 4 || mixed.IndexVersion != 1 { t.Fatalf("unexpected mixed fixture: %+v", mixed) } - if _, err := PreviewMigration(review, history, mixedLedger); err == nil { + if _, err := reviewv4.LoadProjection(compatibilityArtifact(t, "mixed", ReviewRelativePath), compatibilityArtifact(t, "mixed", HistoryRelativePath), compatibilityArtifact(t, "mixed", LedgerRelativePath), compatibilityArtifact(t, "mixed", SessionIndexRelativePath)); err == nil { t.Fatal("mixed fixture was accepted") } } +func compatibilityArtifact(t *testing.T, fixture, relative string) []byte { + t.Helper() + body, err := os.ReadFile(filepath.Join("../../testdata/contracts/migration", fixture, relative)) + if err != nil { + t.Fatal(err) + } + return body +} + +func materializeCompatibilityFixture(t *testing.T, fixture string, includeIndex bool) string { + t.Helper() + root := t.TempDir() + paths := []string{ReviewRelativePath, HistoryRelativePath, LedgerRelativePath} + if includeIndex { + paths = append(paths, SessionIndexRelativePath) + } + for _, relative := range paths { + destination := filepath.Join(root, filepath.FromSlash(relative)) + if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(destination, compatibilityArtifact(t, fixture, relative), 0o600); err != nil { + t.Fatal(err) + } + } + return root +} + func loadCompatibilityFixtures(t *testing.T) map[string]compatibilityFixture { t.Helper() result := make(map[string]compatibilityFixture) @@ -253,6 +269,25 @@ func TestMigrationPreviewBindsEveryFreshnessInputAndIsDeterministic(t *testing.T } } +func TestMigrationPreviewDigestDoesNotMutateNestedCallerState(t *testing.T) { + preview := MigrationPreview{ + PreservedDecisionIDs: []string{"decision-z", "decision-a"}, + DefaultedFields: map[string][]string{"decision-z": {"z", "a"}}, + SessionViewDependencyDigests: []string{"sha256:" + strings.Repeat("2", 64), "sha256:" + strings.Repeat("1", 64)}, + } + want := MigrationPreview{ + PreservedDecisionIDs: append([]string(nil), preview.PreservedDecisionIDs...), + DefaultedFields: map[string][]string{"decision-z": append([]string(nil), preview.DefaultedFields["decision-z"]...)}, + SessionViewDependencyDigests: append([]string(nil), preview.SessionViewDependencyDigests...), + } + if digest := MigrationPreviewDigest(preview); digest == "" { + t.Fatal("digest is empty") + } + if !reflect.DeepEqual(preview, want) { + t.Fatalf("digest mutated caller state:\n got %+v\nwant %+v", preview, want) + } +} + func TestPreviewMigrationRejectsMixedOrNewerSources(t *testing.T) { review, history, machine, _ := migrationFixture(t) if _, err := PreviewMigration(review, history, machine); err != nil { diff --git a/internal/migrationv4/plan.go b/internal/migrationv4/plan.go index 761861f..19e97a4 100644 --- a/internal/migrationv4/plan.go +++ b/internal/migrationv4/plan.go @@ -93,18 +93,18 @@ func validatePreview(preview MigrationPreview) error { } func normalizePreview(preview *MigrationPreview) { + preview.PreservedDecisionIDs = append([]string(nil), preview.PreservedDecisionIDs...) sort.Strings(preview.PreservedDecisionIDs) if preview.PreservedDecisionIDs == nil { preview.PreservedDecisionIDs = []string{} } - if preview.DefaultedFields == nil { - preview.DefaultedFields = map[string][]string{} - } + defaults := make(map[string][]string, len(preview.DefaultedFields)) for key, values := range preview.DefaultedFields { copyValues := append([]string(nil), values...) sort.Strings(copyValues) - preview.DefaultedFields[key] = copyValues + defaults[key] = copyValues } + preview.DefaultedFields = defaults preview.SessionViewDependencyDigests = sortedUnique(preview.SessionViewDependencyDigests) } diff --git a/internal/publication/service.go b/internal/publication/service.go index 2b2ea5f..f34689e 100644 --- a/internal/publication/service.go +++ b/internal/publication/service.go @@ -17,10 +17,13 @@ import ( "github.com/neomei/SessionReviewer/internal/atomicfile" "github.com/neomei/SessionReviewer/internal/config" + "github.com/neomei/SessionReviewer/internal/memory" "github.com/neomei/SessionReviewer/internal/memorystore" "github.com/neomei/SessionReviewer/internal/pathguard" "github.com/neomei/SessionReviewer/internal/presentation" + "github.com/neomei/SessionReviewer/internal/publicationlock" "github.com/neomei/SessionReviewer/internal/reviewv2" + "github.com/neomei/SessionReviewer/internal/reviewv4" syncengine "github.com/neomei/SessionReviewer/internal/sync" "github.com/neomei/SessionReviewer/internal/syncdoc" "github.com/neomei/SessionReviewer/internal/syncproject" @@ -35,7 +38,8 @@ type Options struct { DataRoot string Now func() time.Time - checkpoint func(publishCheckpoint, string, string) error + checkpoint func(publishCheckpoint, string, string) error + publicationLockTimeout time.Duration } type publishCheckpoint string @@ -43,6 +47,7 @@ type publishCheckpoint string const ( checkpointAfterDestination publishCheckpoint = "after_destination" checkpointBeforePointerCommit publishCheckpoint = "before_pointer_commit" + checkpointAfterPointerCommit publishCheckpoint = "after_pointer_commit" ) // VerifiedFile captures one verified file on disk after publication. @@ -78,8 +83,46 @@ var ( const sessionIndexRelativePath = "docs/session-review/.session-reviewer/session-index.json" -// Publish executes the complete durable cross-root publication workflow. -func Publish(ctx context.Context, opts Options) (Result, error) { +// Publish acquires the per-project public-projection lock and executes the +// complete durable cross-root publication workflow. +func Publish(ctx context.Context, opts Options) (_ Result, retErr error) { + if opts.ProjectID == "" || !journalIDPattern.MatchString(opts.ProjectID) { + return Result{}, errors.New("valid project ID is required") + } + if !filepath.IsAbs(opts.DataRoot) || filepath.Clean(opts.DataRoot) != opts.DataRoot { + return Result{}, errors.New("SessionReviewer data root must be an absolute clean path") + } + timeout := opts.publicationLockTimeout + if timeout == 0 { + // Zero is reserved for the nonblocking test seam. Production callers + // that do not set it receive the normal bounded wait. + timeout = 10 * time.Second + } + if opts.publicationLockTimeout < 0 { + timeout = 0 + } + owner, err := publicationlock.Acquire(opts.DataRoot, opts.ProjectID, timeout) + if err != nil { + return Result{}, err + } + defer func() { retErr = errors.Join(retErr, owner.Release()) }() + return PublishLocked(ctx, opts, owner) +} + +// PublishLocked publishes with an already-held ownership token. It exists so +// migration can keep the same OS lock from preview recomputation through the +// durable pointer commit without recursively acquiring it. +func PublishLocked(ctx context.Context, opts Options, owner *publicationlock.Owner) (Result, error) { + var result Result + err := owner.Use(opts.DataRoot, opts.ProjectID, func() error { + var err error + result, err = publishWithOwnership(ctx, opts) + return err + }) + return result, err +} + +func publishWithOwnership(ctx context.Context, opts Options) (Result, error) { if ctx == nil { return Result{}, errors.New("publication context is required") } @@ -129,6 +172,18 @@ func Publish(ctx context.Context, opts Options) (Result, error) { return Result{}, fmt.Errorf("open vault root: %w", err) } defer vaultDir.Close() + + prepared, manifest, err := store.LoadPrepared() + if err != nil { + return Result{}, fmt.Errorf("load prepared generation: %w", err) + } + if prepared.GenerationID != opts.PreparedGeneration { + return Result{}, fmt.Errorf("prepared generation ID %q does not match requested %q", prepared.GenerationID, opts.PreparedGeneration) + } + projectionVersion, err := authenticatePublicationPlan(opts, prepared, manifest) + if err != nil { + return Result{}, fmt.Errorf("authenticate publication projection: %w", err) + } if err := repairRetainedRollbackEvidence(j, opts, projectDir, vaultDir, now); err != nil { return Result{}, fmt.Errorf("repair legacy rolled-back merge-base state: %w", err) } @@ -148,6 +203,22 @@ func Publish(ctx context.Context, opts Options) (Result, error) { recovered := false recoveryHandler := RecoveryHandlerFunc(func(ctx context.Context, intent Intent, j *Journal) error { recovered = true + publishedID, _, publishedErr := store.LoadPublished() + if publishedErr == nil { + if publishedID != intent.GenerationID { + return fmt.Errorf("published generation %q does not match active publication intent %q", publishedID, intent.GenerationID) + } + if intent.Stage != StageVerified { + return errors.New("published generation has a publication journal that was not verified") + } + if err := verifyIntentDesired(ctx, intent, projectDir, vaultDir); err != nil { + return fmt.Errorf("published generation destinations do not match verified intent: %w", err) + } + return j.Advance(StageVerified, StageCommitted) + } + if publishedErr != nil && !errors.Is(publishedErr, memorystore.ErrNoPublishedGeneration) { + return fmt.Errorf("inspect published generation during recovery: %w", publishedErr) + } return rollback(ctx, intent) }) if err := j.Recover(ctx, recoveryHandler); err != nil { @@ -163,14 +234,6 @@ func Publish(ctx context.Context, opts Options) (Result, error) { } } - prepared, manifest, err := store.LoadPrepared() - if err != nil { - return Result{}, fmt.Errorf("load prepared generation: %w", err) - } - if prepared.GenerationID != opts.PreparedGeneration { - return Result{}, fmt.Errorf("prepared generation ID %q does not match requested %q", prepared.GenerationID, opts.PreparedGeneration) - } - // Pre-flight check Project files against expected plan preimages for _, file := range opts.Plan.Files { body, found, err := projectDir.ReadRegularOptional(file.Relative, 64<<20) @@ -251,11 +314,6 @@ func Publish(ctx context.Context, opts Options) (Result, error) { return Result{}, fmt.Errorf("create journal intent: %w", err) } - projectionVersion, err := planProjectionVersion(opts.Plan) - if err != nil { - return Result{}, rollbackFailure(ctx, intent, err) - } - // Write Project files for _, file := range opts.Plan.Files { if err := verifyDestinationPreimage(intent.Destinations, projectDir, "project", file.Relative); err != nil { @@ -351,6 +409,9 @@ func Publish(ctx context.Context, opts Options) (Result, error) { if err := store.CommitPublished(opts.PreparedGeneration, proof); err != nil { return Result{}, rollbackFailure(ctx, intent, fmt.Errorf("commit published generation: %w", err)) } + if err := runPublishCheckpoint(opts, checkpointAfterPointerCommit, "", ""); err != nil { + return Result{}, err + } if err := j.Advance(StageVerified, StageCommitted); err != nil { return Result{}, err } @@ -496,6 +557,45 @@ func planProjectionVersion(plan presentation.RenderPlan) (int, error) { return 3, nil } +func authenticatePublicationPlan(opts Options, prepared memorystore.Prepared, manifest memory.GenerationManifest) (int, error) { + version, err := planProjectionVersion(opts.Plan) + if err != nil { + return 0, err + } + if manifest.ProjectID != opts.ProjectID || manifest.GenerationID != opts.PreparedGeneration || + prepared.GenerationID != manifest.GenerationID || prepared.ProjectViewDigest != manifest.ProjectViewDigest || + opts.Plan.ProjectID != manifest.ProjectID || opts.Plan.GenerationID != manifest.GenerationID || + opts.Plan.ProjectViewDigest != manifest.ProjectViewDigest { + return 0, errors.New("publication plan, prepared pointer, and manifest identity do not match") + } + files := make(map[string][]byte, len(opts.Plan.Files)) + for _, file := range opts.Plan.Files { + files[file.Relative] = file.Desired + } + var projectID, generationID, projectViewDigest string + if version == 3 { + accepted, err := reviewv2.LoadV3Bytes(files[reviewv2.ReviewRelativePath], files[reviewv2.HistoryRelativePath], files[reviewv2.MachineLedgerRelativePath]) + if err != nil { + return 0, fmt.Errorf("load v3 projection: %w", err) + } + projectID = accepted.State.Machine.ProjectID + generationID = accepted.State.Machine.GenerationID + projectViewDigest = "sha256:" + accepted.State.Machine.ProjectViewDigest + } else { + accepted, err := reviewv4.LoadProjection(files[reviewv2.ReviewRelativePath], files[reviewv2.HistoryRelativePath], files[reviewv2.MachineLedgerRelativePath], files[sessionIndexRelativePath]) + if err != nil { + return 0, fmt.Errorf("load v4 projection: %w", err) + } + projectID = accepted.Review.ProjectID + generationID = accepted.Review.GenerationID + projectViewDigest = accepted.Review.ProjectViewDigest + } + if projectID != manifest.ProjectID || generationID != manifest.GenerationID || projectViewDigest != manifest.ProjectViewDigest { + return 0, errors.New("projection identity does not match prepared manifest") + } + return version, nil +} + func syncReportReadyToApply(report syncengine.Report) bool { return len(report.Conflicts) == 0 && len(report.Issues) == 0 && @@ -797,6 +897,35 @@ func verifyDestinationPreimage(destinations []Destination, directory *pathguard. return errors.New("publication destination is missing from intent") } +func verifyIntentDesired(ctx context.Context, intent Intent, projectDir, vaultDir *pathguard.Directory) error { + for _, destination := range intent.Destinations { + if err := ctx.Err(); err != nil { + return err + } + var directory *pathguard.Directory + switch destination.Side { + case "project": + directory = projectDir + case "vault": + directory = vaultDir + default: + return errors.New("publication intent contains an unknown destination side") + } + body, found, err := directory.ReadRegularOptional(destination.Relative, 64<<20) + if err != nil { + return err + } + actual := "missing" + if found { + actual = sha256Hex(body) + } + if !found || !strings.EqualFold(actual, destination.DesiredSHA256) { + return fmt.Errorf("%w: %w", ErrPublicationConflict, &PublicationConflictError{Side: destination.Side, Relative: destination.Relative, Expected: destination.DesiredSHA256, Actual: actual}) + } + } + return nil +} + func sha256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) diff --git a/internal/publication/service_test.go b/internal/publication/service_test.go index 8dfef4c..3398794 100644 --- a/internal/publication/service_test.go +++ b/internal/publication/service_test.go @@ -17,10 +17,13 @@ import ( "github.com/neomei/SessionReviewer/internal/config" "github.com/neomei/SessionReviewer/internal/memory" "github.com/neomei/SessionReviewer/internal/memorystore" + "github.com/neomei/SessionReviewer/internal/migrationv4" "github.com/neomei/SessionReviewer/internal/pathguard" "github.com/neomei/SessionReviewer/internal/platform" "github.com/neomei/SessionReviewer/internal/presentation" + "github.com/neomei/SessionReviewer/internal/project" "github.com/neomei/SessionReviewer/internal/reviewv2" + "github.com/neomei/SessionReviewer/internal/sessionindex" syncengine "github.com/neomei/SessionReviewer/internal/sync" ) @@ -314,17 +317,10 @@ func TestPublishCleanRunSucceeds(t *testing.T) { } } -func TestPublishFourFilePlanVerifiesSessionIndexBeforePointerCommit(t *testing.T) { +func TestPublishFourFilePlanAuthenticatesValidProjectionBeforePointerCommit(t *testing.T) { projectID := "project-four-file" dataRoot, projectRoot, vaultRoot, mapping, manifest, plan := setupPublishEnv(t, projectID) - // A v4 review is JSON, not a syncdoc Markdown document. If the generic - // four-file path accidentally delegates to the legacy sync engine this - // publication fails before the Vault files can be verified. - plan.Files[0].Desired = []byte("{\"schema_version\":4}\n") - indexBody := []byte("{\"schema_version\":1}\n") - plan.Files = append(plan.Files, presentation.FilePlan{ - Relative: "docs/session-review/.session-reviewer/session-index.json", Desired: indexBody, Mode: 0o600, - }) + plan = validV4PublicationPlan(t, manifest, plan) result, err := Publish(context.Background(), Options{ ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, Mapping: mapping, DataRoot: dataRoot, Now: time.Now, @@ -340,7 +336,7 @@ func TestPublishFourFilePlanVerifiesSessionIndexBeforePointerCommit(t *testing.T filepath.Join(vaultRoot, filepath.FromSlash(mapping.VaultReviewPath), ".session-reviewer", "session-index.json"), } { got, err := os.ReadFile(target) - if err != nil || !bytes.Equal(got, indexBody) { + if err != nil || !bytes.Equal(got, plan.Files[3].Desired) { t.Fatalf("session index at %s = %q, %v", target, got, err) } } @@ -354,19 +350,96 @@ func TestPublishFourFilePlanVerifiesSessionIndexBeforePointerCommit(t *testing.T } } +func TestPublishRejectsUnauthenticatedProjectionBeforeIntent(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*presentation.RenderPlan) + }{ + {name: "v3 schema stub", mutate: func(plan *presentation.RenderPlan) { plan.Files[0].Desired = []byte("---\nschema_version: 3\n---\n") }}, + {name: "v4 schema stub", mutate: func(plan *presentation.RenderPlan) { plan.Files[0].Desired = []byte("{\"schema_version\":4}\n") }}, + {name: "mixed index", mutate: func(plan *presentation.RenderPlan) { plan.Files[3].Desired = []byte("{\"schema_version\":1}\n") }}, + {name: "plan project view mismatch", mutate: func(plan *presentation.RenderPlan) { plan.ProjectViewDigest = prefixedDigest("other-view") }}, + } { + t.Run(test.name, func(t *testing.T) { + projectID := "project-auth-" + strings.ReplaceAll(test.name, " ", "-") + dataRoot, _, _, mapping, manifest, v3Plan := setupPublishEnv(t, projectID) + plan := v3Plan + if test.name != "v3 schema stub" { + plan = validV4PublicationPlan(t, manifest, v3Plan) + } + test.mutate(&plan) + _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }) + if err == nil { + t.Fatal("unauthenticated projection was published") + } + journal, openErr := OpenJournal(dataRoot, projectID) + if openErr != nil { + t.Fatal(openErr) + } + defer journal.Close() + if _, loadErr := journal.Load(); !errors.Is(loadErr, ErrNoActiveIntent) { + t.Fatalf("invalid projection created an intent: %v", loadErr) + } + }) + } +} + +func TestPublishSerializesEveryCallerWithProjectPublicationLock(t *testing.T) { + projectID := "project-publication-lock" + dataRoot, _, _, mapping, manifest, v3Plan := setupPublishEnv(t, projectID) + plan := validV4PublicationPlan(t, manifest, v3Plan) + entered := make(chan struct{}) + release := make(chan struct{}) + firstDone := make(chan error, 1) + go func() { + _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + checkpoint: func(stage publishCheckpoint, side, relative string) error { + if stage == checkpointAfterDestination && side == "project" { + select { + case <-entered: + default: + close(entered) + <-release + } + } + return nil + }, + }) + firstDone <- err + }() + <-entered + _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, publicationLockTimeout: -1, + }) + if !errors.Is(err, project.ErrProjectLocked) { + close(release) + t.Fatalf("concurrent publisher error = %v", err) + } + close(release) + if err := <-firstDone; err != nil { + t.Fatal(err) + } + if _, err := PublishLocked(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }, nil); err == nil { + t.Fatal("PublishLocked accepted a missing ownership token") + } +} + // Moving the published-generation commit above complete destination // verification makes this test expose a new generation while the public atom // has already been rolled back to its old preimages. func TestPublishFourFilePlanCommitsPointerLast(t *testing.T) { projectID := "project-pointer-last" dataRoot, projectRoot, vaultRoot, mapping, manifest, plan := setupPublishEnv(t, projectID) - for index := range plan.Files { - plan.Files[index].Desired = []byte(fmt.Sprintf("v4-target-%d\n", index)) - } - plan.Files = append(plan.Files, presentation.FilePlan{ - Relative: "docs/session-review/.session-reviewer/session-index.json", - Desired: []byte("v4-target-index\n"), Mode: 0o600, - }) + plan = validV4PublicationPlan(t, manifest, plan) stop := errors.New("stop before published pointer") opts := Options{ ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, @@ -401,17 +474,207 @@ func TestPublishFourFilePlanCommitsPointerLast(t *testing.T) { } } +func TestPublishFourFilePlanRecoversForwardAfterPointerCommit(t *testing.T) { + projectID := "project-forward-recovery" + dataRoot, projectRoot, vaultRoot, mapping, manifest, v3Plan := setupPublishEnv(t, projectID) + plan := validV4PublicationPlan(t, manifest, v3Plan) + stop := errors.New("simulated crash after pointer commit") + _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + checkpoint: func(stage publishCheckpoint, side, relative string) error { + if stage == checkpointAfterPointerCommit { + return stop + } + return nil + }, + }) + if !errors.Is(err, stop) { + t.Fatalf("Publish error = %v, want post-pointer checkpoint", err) + } + store, err := memorystore.Open(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + published, _, err := store.LoadPublished() + if closeErr := store.Close(); err != nil || closeErr != nil || published != manifest.GenerationID { + t.Fatalf("durable pointer = %q load=%v close=%v", published, err, closeErr) + } + assertV4PublicationTargets(t, projectRoot, vaultRoot, mapping, plan) + journal, err := OpenJournal(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + intent, err := journal.Load() + if closeErr := journal.Close(); err != nil || closeErr != nil || intent.Stage != StageVerified { + t.Fatalf("crash journal = %+v load=%v close=%v", intent, err, closeErr) + } + + result, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }) + if err != nil || !result.Recovered || result.GenerationID != manifest.GenerationID { + t.Fatalf("forward recovery result=%+v err=%v", result, err) + } + assertV4PublicationTargets(t, projectRoot, vaultRoot, mapping, plan) + journal, err = OpenJournal(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + defer journal.Close() + intent, err = journal.Load() + if err != nil || intent.Stage != StageCommitted { + t.Fatalf("forward recovery journal = %+v err=%v", intent, err) + } +} + +func TestPublishForwardRecoveryFailsClosedWhenPublishedAtomDiffers(t *testing.T) { + projectID := "project-forward-recovery-mismatch" + dataRoot, projectRoot, vaultRoot, mapping, manifest, v3Plan := setupPublishEnv(t, projectID) + plan := validV4PublicationPlan(t, manifest, v3Plan) + stop := errors.New("simulated crash after pointer commit") + _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + checkpoint: func(stage publishCheckpoint, side, relative string) error { + if stage == checkpointAfterPointerCommit { + return stop + } + return nil + }, + }) + if !errors.Is(err, stop) { + t.Fatalf("Publish error = %v, want post-pointer checkpoint", err) + } + tamperedPath := filepath.Join(projectRoot, filepath.FromSlash(plan.Files[0].Relative)) + tampered := []byte("tampered after durable pointer\n") + if err := os.WriteFile(tamperedPath, tampered, plan.Files[0].Mode); err != nil { + t.Fatal(err) + } + + if _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }); err == nil { + t.Fatal("forward recovery accepted a destination that differs from the durable published atom") + } + if body, err := os.ReadFile(tamperedPath); err != nil || !bytes.Equal(body, tampered) { + t.Fatalf("mismatch was rolled back behind the published pointer: body=%q err=%v", body, err) + } + untampered := filepath.Join(vaultRoot, filepath.FromSlash(vaultRelativePath(mapping.VaultReviewPath, plan.Files[1].Relative))) + if body, err := os.ReadFile(untampered); err != nil || !bytes.Equal(body, plan.Files[1].Desired) { + t.Fatalf("desired destination was rolled back behind the published pointer: body=%q err=%v", body, err) + } + store, err := memorystore.Open(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + published, _, loadErr := store.LoadPublished() + closeErr := store.Close() + if loadErr != nil || closeErr != nil || published != manifest.GenerationID { + t.Fatalf("published pointer changed during failed recovery: generation=%q load=%v close=%v", published, loadErr, closeErr) + } + journal, err := OpenJournal(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + intent, loadErr := journal.Load() + closeErr = journal.Close() + if loadErr != nil || closeErr != nil || intent.Stage != StageVerified { + t.Fatalf("failed recovery changed durable journal: intent=%+v load=%v close=%v", intent, loadErr, closeErr) + } +} + +func TestPublishForwardRecoveryFailsClosedWhenPublishedPointerNamesDifferentGeneration(t *testing.T) { + projectID := "project-forward-recovery-newer-pointer" + dataRoot, projectRoot, vaultRoot, mapping, manifest, v3Plan := setupPublishEnv(t, projectID) + plan := validV4PublicationPlan(t, manifest, v3Plan) + if _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }); err != nil { + t.Fatal(err) + } + + journal, err := OpenJournal(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + destinations := make([]Destination, 0, len(plan.Files)*2) + for _, file := range plan.Files { + preimage := []byte("stale generation preimage for " + file.Relative + "\n") + preimageSHA := sha256Hex(preimage) + if err := journal.PutPreimage(preimageSHA, preimage); err != nil { + journal.Close() + t.Fatal(err) + } + destinations = append(destinations, + Destination{Side: "project", Relative: file.Relative, PreimageSHA256: preimageSHA, DesiredSHA256: sha256Hex(file.Desired), PreimageExists: true}, + Destination{Side: "vault", Relative: vaultRelativePath(mapping.VaultReviewPath, file.Relative), PreimageSHA256: preimageSHA, DesiredSHA256: sha256Hex(file.Desired), PreimageExists: true}, + ) + } + sort.Slice(destinations, func(i, j int) bool { + if destinations[i].Side != destinations[j].Side { + return destinations[i].Side < destinations[j].Side + } + return destinations[i].Relative < destinations[j].Relative + }) + intent := Intent{ + Version: 1, ProjectID: projectID, GenerationID: "generation-stale-publication", + ManifestDigest: prefixedDigest("stale-manifest"), ProjectViewDigest: prefixedDigest("stale-project-view"), + Stage: StagePrepared, CreatedAt: time.Now().UTC(), Destinations: destinations, + } + if err := journal.Create(intent); err != nil { + journal.Close() + t.Fatal(err) + } + for _, transition := range [][2]Stage{{StagePrepared, StageProjectWritten}, {StageProjectWritten, StageVaultSynced}, {StageVaultSynced, StageVerified}} { + if err := journal.Advance(transition[0], transition[1]); err != nil { + journal.Close() + t.Fatal(err) + } + } + if err := journal.Close(); err != nil { + t.Fatal(err) + } + + if _, err := Publish(context.Background(), Options{ + ProjectID: projectID, PreparedGeneration: manifest.GenerationID, Plan: plan, + Mapping: mapping, DataRoot: dataRoot, Now: time.Now, + }); err == nil { + t.Fatal("recovery accepted a verified intent behind a different published generation") + } + assertV4PublicationTargets(t, projectRoot, vaultRoot, mapping, plan) + + store, err := memorystore.Open(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + published, _, loadErr := store.LoadPublished() + closeErr := store.Close() + if loadErr != nil || closeErr != nil || published != manifest.GenerationID { + t.Fatalf("published pointer changed during failed recovery: generation=%q load=%v close=%v", published, loadErr, closeErr) + } + journal, err = OpenJournal(dataRoot, projectID) + if err != nil { + t.Fatal(err) + } + intent, loadErr = journal.Load() + closeErr = journal.Close() + if loadErr != nil || closeErr != nil || intent.Stage != StageVerified || intent.GenerationID != "generation-stale-publication" { + t.Fatalf("failed recovery changed durable journal: intent=%+v load=%v close=%v", intent, loadErr, closeErr) + } +} + func TestPublishFourFilePlanRecoversGenericIntentAfterRestart(t *testing.T) { projectID := "project-four-file-recovery" dataRoot, projectRoot, vaultRoot, mapping, manifest, plan := setupPublishEnv(t, projectID) - plan.Files = append(plan.Files, presentation.FilePlan{ - Relative: sessionIndexRelativePath, Desired: []byte("v4-index-target\n"), Mode: 0o600, - }) + plan = validV4PublicationPlan(t, manifest, plan) for index := range plan.Files { old := []byte(fmt.Sprintf("old-four-file-%d\n", index)) plan.Files[index].ExpectedExists = true plan.Files[index].Expected = old - plan.Files[index].Desired = []byte(fmt.Sprintf("new-four-file-%d\n", index)) for _, target := range []string{ filepath.Join(projectRoot, filepath.FromSlash(plan.Files[index].Relative)), filepath.Join(vaultRoot, filepath.FromSlash(vaultRelativePath(mapping.VaultReviewPath, plan.Files[index].Relative))), @@ -533,6 +796,55 @@ func TestPublishFourFilePlanRecoversGenericIntentAfterRestart(t *testing.T) { } } +func validV4PublicationPlan(t *testing.T, manifest memory.GenerationManifest, v3Plan presentation.RenderPlan) presentation.RenderPlan { + t.Helper() + byPath := make(map[string]presentation.FilePlan, len(v3Plan.Files)) + for _, file := range v3Plan.Files { + byPath[file.Relative] = file + } + indexBody, err := sessionindex.Render(sessionindex.Document{ + SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: manifest.ProjectID, + GenerationID: manifest.GenerationID, ProjectViewDigest: manifest.ProjectViewDigest, + GeneratedAt: manifest.CreatedAt, SortVersion: sessionindex.SortVersion, + Coverage: sessionindex.IndexCoverage{}, Sessions: []sessionindex.Entry{}, + }) + if err != nil { + t.Fatal(err) + } + migrated, err := migrationv4.BuildPreview(migrationv4.Input{ + Review: byPath[reviewv2.ReviewRelativePath].Desired, History: byPath[reviewv2.HistoryRelativePath].Desired, + Ledger: byPath[reviewv2.MachineLedgerRelativePath].Desired, SessionIndex: indexBody, + GenerationID: manifest.GenerationID, TargetPreimages: map[string]migrationv4.Preimage{}, + }) + if err != nil { + t.Fatal(err) + } + return presentation.RenderPlan{ + ProjectID: manifest.ProjectID, GenerationID: manifest.GenerationID, ProjectViewDigest: manifest.ProjectViewDigest, + Files: []presentation.FilePlan{ + {Relative: migrationv4.ReviewRelativePath, Desired: migrated.Review, Mode: 0o644}, + {Relative: migrationv4.HistoryRelativePath, Desired: migrated.History, Mode: 0o644}, + {Relative: migrationv4.LedgerRelativePath, Desired: migrated.Ledger, Mode: 0o600}, + {Relative: migrationv4.SessionIndexRelativePath, Desired: migrated.SessionIndex, Mode: 0o600}, + }, + } +} + +func assertV4PublicationTargets(t *testing.T, projectRoot, vaultRoot string, mapping config.ProjectMapping, plan presentation.RenderPlan) { + t.Helper() + for _, file := range plan.Files { + for _, target := range []string{ + filepath.Join(projectRoot, filepath.FromSlash(file.Relative)), + filepath.Join(vaultRoot, filepath.FromSlash(vaultRelativePath(mapping.VaultReviewPath, file.Relative))), + } { + body, err := os.ReadFile(target) + if err != nil || !bytes.Equal(body, file.Desired) { + t.Fatalf("publication target %s = %q, %v", target, body, err) + } + } + } +} + func TestPublishPreimageMismatchFailsClosed(t *testing.T) { projectID := "project-conflict" dataRoot, projectRoot, _, mapping, manifest, plan := setupPublishEnv(t, projectID) diff --git a/internal/publicationlock/lock.go b/internal/publicationlock/lock.go new file mode 100644 index 0000000..54fc5af --- /dev/null +++ b/internal/publicationlock/lock.go @@ -0,0 +1,95 @@ +// Package publicationlock owns the one OS advisory lock shared by every +// public-projection publisher for a project. +package publicationlock + +import ( + "errors" + "path/filepath" + "regexp" + "sync" + "time" + + "github.com/neomei/SessionReviewer/internal/pathguard" + "github.com/neomei/SessionReviewer/internal/project" +) + +const relativePath = "publication.lock" + +var projectIDPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,127}$`) + +// Owner is an unforgeable in-process capability for one held publication +// lock. Its fields are private so callers can only obtain it through Acquire. +type Owner struct { + mu sync.Mutex + lock *project.ProjectLock + directory *pathguard.Directory + dataRoot string + projectID string +} + +func Acquire(dataRoot, projectID string, timeout time.Duration) (*Owner, error) { + if dataRoot == "" || !filepath.IsAbs(dataRoot) || filepath.Clean(dataRoot) != dataRoot || !projectIDPattern.MatchString(projectID) { + return nil, errors.New("valid publication lock identity is required") + } + data, err := pathguard.Open(dataRoot) + if err != nil { + return nil, err + } + closeData := true + defer func() { + if closeData { + _ = data.Close() + } + }() + for _, relative := range []string{"publication-locks", "publication-locks/" + projectID} { + if err := data.EnsureDirectory(relative, 0o700); err != nil { + return nil, err + } + } + directory, err := pathguard.Open(filepath.Join(dataRoot, "publication-locks", projectID)) + if err != nil { + return nil, err + } + lock, err := project.AcquireProjectLock(directory.Root, relativePath, timeout) + if err != nil { + _ = directory.Close() + return nil, err + } + closeData = false + if err := data.Close(); err != nil { + _ = lock.Release() + _ = directory.Close() + return nil, err + } + return &Owner{lock: lock, directory: directory, dataRoot: dataRoot, projectID: projectID}, nil +} + +// Use validates the ownership identity and prevents Release from racing the +// operation performed under the held OS lock. +func (owner *Owner) Use(dataRoot, projectID string, operation func() error) error { + if owner == nil || operation == nil { + return errors.New("publication lock ownership token is required") + } + owner.mu.Lock() + defer owner.mu.Unlock() + if owner.lock == nil || owner.dataRoot != dataRoot || owner.projectID != projectID { + return errors.New("publication lock ownership token does not match project") + } + return operation() +} + +func (owner *Owner) Release() error { + if owner == nil { + return nil + } + owner.mu.Lock() + defer owner.mu.Unlock() + if owner.lock == nil { + return nil + } + lock := owner.lock + directory := owner.directory + owner.lock = nil + owner.directory = nil + return errors.Join(lock.Release(), directory.Close()) +} diff --git a/internal/syncproject/migration.go b/internal/syncproject/migration.go index 0d3c0b4..020d784 100644 --- a/internal/syncproject/migration.go +++ b/internal/syncproject/migration.go @@ -15,6 +15,7 @@ import ( "github.com/neomei/SessionReviewer/internal/migrationv4" "github.com/neomei/SessionReviewer/internal/presentation" "github.com/neomei/SessionReviewer/internal/project" + "github.com/neomei/SessionReviewer/internal/publicationlock" "github.com/neomei/SessionReviewer/internal/sessionindex" ) @@ -37,6 +38,7 @@ type MigrationPublication struct { Mapping config.ProjectMapping DataRoot string Preview migrationv4.MigrationPreview + PublicationLock *publicationlock.Owner syncDataRoot *os.Root } @@ -75,6 +77,11 @@ func RunMigration(ctx context.Context, options MigrationOptions) (_ MigrationRes return MigrationResult{}, err } defer func() { retErr = errors.Join(retErr, pin.Close()) }() + publicationOwner, err := publicationlock.Acquire(pin.data.Path, pin.mapping.ID, 10*time.Second) + if err != nil { + return MigrationResult{}, errors.New("public projection is locked or unsafe") + } + defer func() { retErr = errors.Join(retErr, publicationOwner.Release()) }() lock, err := project.AcquireProjectLock(pin.syncData.Root, "locks/sync.lock", 10*time.Second) if err != nil { return MigrationResult{}, errors.New("sync project is locked or unsafe") @@ -112,7 +119,8 @@ func RunMigration(ctx context.Context, options MigrationOptions) (_ MigrationRes Files: migrationFilePlan(plan), }, Mapping: pin.mapping, DataRoot: pin.data.Path, Preview: plan.Preview, - syncDataRoot: pin.syncData.Root, + PublicationLock: publicationOwner, + syncDataRoot: pin.syncData.Root, } if err := options.Publish(ctx, publication); err != nil { return MigrationResult{}, err diff --git a/internal/syncproject/service_test.go b/internal/syncproject/service_test.go index ea1f4ef..464177d 100644 --- a/internal/syncproject/service_test.go +++ b/internal/syncproject/service_test.go @@ -21,6 +21,7 @@ import ( "github.com/neomei/SessionReviewer/internal/migrationv4" "github.com/neomei/SessionReviewer/internal/platform" "github.com/neomei/SessionReviewer/internal/project" + "github.com/neomei/SessionReviewer/internal/publicationlock" "github.com/neomei/SessionReviewer/internal/reviewv2" syncengine "github.com/neomei/SessionReviewer/internal/sync" ) @@ -33,8 +34,15 @@ func TestSyncProjectMigrationConfirmationRecomputesUnderProjectLock(t *testing.T options := MigrationOptions{ Options: Options{ProjectID: fixture.projectID, CWD: fixture.project, DataDir: fixture.data, GOOS: runtime.GOOS, Now: time.Now, Trigger: syncengine.TriggerCLI}, Mode: MigrationDryRun, - build: func(*MappingPin) (migrationv4.Result, error) { + build: func(pin *MappingPin) (migrationv4.Result, error) { buildCalls++ + contender, err := publicationlock.Acquire(pin.data.Path, pin.mapping.ID, 0) + if contender != nil { + _ = contender.Release() + } + if !errors.Is(err, project.ErrProjectLocked) { + t.Fatalf("preview recomputation ran without publication lock: %v", err) + } return migrationv4.Result{Preview: preview}, nil }, Publish: func(_ context.Context, publication MigrationPublication) error { @@ -42,6 +50,16 @@ func TestSyncProjectMigrationConfirmationRecomputesUnderProjectLock(t *testing.T if publication.Preview.PreviewDigest != preview.PreviewDigest { t.Fatalf("publication preview = %+v", publication.Preview) } + if publication.PublicationLock == nil { + t.Fatal("publisher did not receive publication lock ownership") + } + contender, publicationErr := publicationlock.Acquire(publication.DataRoot, publication.ProjectID, 0) + if contender != nil { + _ = contender.Release() + } + if !errors.Is(publicationErr, project.ErrProjectLocked) { + t.Fatalf("publisher ran without publication lock: %v", publicationErr) + } lock, err := project.AcquireProjectLock(publication.syncDataRoot, "locks/sync.lock", 0) if lock != nil { _ = lock.Release() diff --git a/testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/ledger.json new file mode 100644 index 0000000..20992a2 --- /dev/null +++ b/testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/ledger.json @@ -0,0 +1 @@ +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"1beabc6a697b1003cdcac4088767ff2d54e7d82542cad94250da61963a7a117a","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"1beabc6a697b1003cdcac4088767ff2d54e7d82542cad94250da61963a7a117a","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"9900fe4a1da57f615199530d2698eb214cd3858e646c3f57006bae1375417ea9","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} \ No newline at end of file diff --git a/testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/session-index.json b/testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/session-index.json new file mode 100644 index 0000000..f82f344 --- /dev/null +++ b/testdata/contracts/migration/mixed/docs/session-review/.session-reviewer/session-index.json @@ -0,0 +1 @@ +{"schema_version":1,"minimum_reader_version":"0.4.0","digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","generated_at":"2026-09-04T00:00:00Z","sort_version":"started-at-desc-null-last-provider-session-v1","coverage":{"total":0,"complete":0,"partial":0,"error":0,"unprocessed":0,"source_available":0,"source_unavailable":0,"started_at_known":0,"ended_at_known":0,"usage_known":0},"sessions":[]} diff --git "a/testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" "b/testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" new file mode 100644 index 0000000..1648479 --- /dev/null +++ "b/testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" @@ -0,0 +1,12 @@ +--- +id: project-history +entity_type: project_history +project_id: project-compatibility +schema_version: 3 +minimum_writer_version: 0.3.0 +generation_id: generation-compatibility +revision: 3 +--- +# 项目历史 + +> 按时间逆序排列。 diff --git "a/testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" new file mode 100644 index 0000000..733b3a7 --- /dev/null +++ "b/testdata/contracts/migration/mixed/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -0,0 +1,32 @@ +--- +id: project-overview +entity_type: project_review +project_id: project-compatibility +schema_version: 3 +minimum_writer_version: 0.3.0 +generation_id: generation-compatibility +revision: 3 +--- +# Compatibility + +## 项目目标 +Preserve + +## 当前阶段 +migration + +## 当前状态 +active + +## 下一步 +verify + +## 风险与待办 + +## 关键决策 + +## 最近验证 +2026-09-04 + +## 项目历史 +[打开完整项目历史](./项目历史.md) diff --git a/testdata/contracts/migration/newer/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/newer/docs/session-review/.session-reviewer/ledger.json new file mode 100644 index 0000000..20992a2 --- /dev/null +++ b/testdata/contracts/migration/newer/docs/session-review/.session-reviewer/ledger.json @@ -0,0 +1 @@ +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"1beabc6a697b1003cdcac4088767ff2d54e7d82542cad94250da61963a7a117a","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"1beabc6a697b1003cdcac4088767ff2d54e7d82542cad94250da61963a7a117a","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"9900fe4a1da57f615199530d2698eb214cd3858e646c3f57006bae1375417ea9","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} \ No newline at end of file diff --git a/testdata/contracts/migration/newer/docs/session-review/.session-reviewer/session-index.json b/testdata/contracts/migration/newer/docs/session-review/.session-reviewer/session-index.json new file mode 100644 index 0000000..f82f344 --- /dev/null +++ b/testdata/contracts/migration/newer/docs/session-review/.session-reviewer/session-index.json @@ -0,0 +1 @@ +{"schema_version":1,"minimum_reader_version":"0.4.0","digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","generated_at":"2026-09-04T00:00:00Z","sort_version":"started-at-desc-null-last-provider-session-v1","coverage":{"total":0,"complete":0,"partial":0,"error":0,"unprocessed":0,"source_available":0,"source_unavailable":0,"started_at_known":0,"ended_at_known":0,"usage_known":0},"sessions":[]} diff --git "a/testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" "b/testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" new file mode 100644 index 0000000..1648479 --- /dev/null +++ "b/testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" @@ -0,0 +1,12 @@ +--- +id: project-history +entity_type: project_history +project_id: project-compatibility +schema_version: 3 +minimum_writer_version: 0.3.0 +generation_id: generation-compatibility +revision: 3 +--- +# 项目历史 + +> 按时间逆序排列。 diff --git "a/testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" new file mode 100644 index 0000000..8195403 --- /dev/null +++ "b/testdata/contracts/migration/newer/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -0,0 +1 @@ +{"schema_version":5,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","revision":3,"current_state":{"goal":"Preserve","stage":"migration","status":"active","next_action":"verify","last_verification":"2026-09-04"},"timeline":[],"decisions":[],"risks":[],"open_loops":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[]} diff --git a/testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json new file mode 100644 index 0000000..c97eb64 --- /dev/null +++ b/testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json @@ -0,0 +1 @@ +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"f4f61cd8e805ed82aa25f1fbeeaeb67baa838352567409e26036b99dda3ce88c","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} \ No newline at end of file diff --git "a/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" "b/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" new file mode 100644 index 0000000..1648479 --- /dev/null +++ "b/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" @@ -0,0 +1,12 @@ +--- +id: project-history +entity_type: project_history +project_id: project-compatibility +schema_version: 3 +minimum_writer_version: 0.3.0 +generation_id: generation-compatibility +revision: 3 +--- +# 项目历史 + +> 按时间逆序排列。 diff --git "a/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" new file mode 100644 index 0000000..1ca95b5 --- /dev/null +++ "b/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -0,0 +1 @@ +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","revision":3,"current_state":{"goal":"Preserve","stage":"migration","status":"active","next_action":"verify","last_verification":"2026-09-04"},"timeline":[],"decisions":[],"risks":[],"open_loops":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[]} diff --git a/testdata/contracts/migration/v2/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/v2/docs/session-review/.session-reviewer/ledger.json new file mode 100644 index 0000000..751058d --- /dev/null +++ b/testdata/contracts/migration/v2/docs/session-review/.session-reviewer/ledger.json @@ -0,0 +1,36 @@ +{ + "schema_version": 2, + "project_id": "project-compatibility", + "accepted_revision": 2, + "review_sha256": "c8b4497d1b296d09be02935016d21a4d137c4e18f7258f746c6abeba2dbf77a7", + "history_sha256": "57fb4f5ce9b7a73dbaf3bf4297284c6dd24c8a54e433b2d092e3da9a6de707dc", + "accounting": { + "total_duration_ms": 0, + "total_tokens": 0, + "total_cost_usd": 0, + "models": [] + }, + "sessions": [], + "evidence": [], + "legacy_compatibility": { + "current_state": { + "project_id": "project-compatibility", + "revision": 2, + "goal": "preserve compatibility", + "last_verified": "fixture", + "branch": "migration", + "uncommitted_changes": [], + "blockers": [], + "open_risks": [], + "next_action": "migrate", + "first_inspection": "fixture", + "last_updated": "2026-09-04T00:00:00Z", + "source_sessions": [], + "evidence": [] + }, + "timeline": [], + "decisions": [], + "open_loops": [], + "current_risks": [] + } +} diff --git "a/testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" "b/testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" new file mode 100644 index 0000000..15ff76f --- /dev/null +++ "b/testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" @@ -0,0 +1,10 @@ +--- +id: project-history +entity_type: project_history +project_id: project-compatibility +schema_version: 2 +revision: 2 +--- +# 项目历史 + +> 按时间逆序排列。 diff --git "a/testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" new file mode 100644 index 0000000..e6ea924 --- /dev/null +++ "b/testdata/contracts/migration/v2/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -0,0 +1,30 @@ +--- +id: project-overview +entity_type: project_review +project_id: project-compatibility +schema_version: 2 +revision: 2 +--- +# project-compatibility + +## 项目目标 +preserve compatibility + +## 当前阶段 +migration + +## 当前状态 +active + +## 下一步 +migrate + +## 风险与待办 + +## 关键决策 + +## 最近验证 +fixture + +## 项目历史 +[打开完整项目历史](./项目历史.md) diff --git a/testdata/contracts/migration/v3/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/v3/docs/session-review/.session-reviewer/ledger.json new file mode 100644 index 0000000..4ac65e4 --- /dev/null +++ b/testdata/contracts/migration/v3/docs/session-review/.session-reviewer/ledger.json @@ -0,0 +1,41 @@ +{ + "schema_version": 3, + "minimum_writer_version": "0.3.0", + "project_id": "project-compatibility", + "generation_id": "generation-compatibility", + "project_view_digest": "1111111111111111111111111111111111111111111111111111111111111111", + "accepted_revision": 3, + "review_sha256": "fe3f1ca35bb85bddf1533925ee5c796926f6cbe191c9aecf140b492d03463597", + "history_sha256": "bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd", + "accounting": { + "total_duration_ms": 0, + "total_tokens": 0, + "total_cost_usd": 0, + "models": [] + }, + "sessions": [], + "human_patches": [], + "orphan_patches": [], + "generated_baselines": [], + "legacy_compatibility": { + "current_state": { + "project_id": "", + "revision": 0, + "goal": "", + "last_verified": "", + "branch": "", + "uncommitted_changes": [], + "blockers": [], + "open_risks": [], + "next_action": "", + "first_inspection": "", + "last_updated": "", + "source_sessions": [], + "evidence": [] + }, + "timeline": [], + "decisions": [], + "open_loops": [], + "current_risks": [] + } +} diff --git "a/testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" "b/testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" new file mode 100644 index 0000000..1648479 --- /dev/null +++ "b/testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" @@ -0,0 +1,12 @@ +--- +id: project-history +entity_type: project_history +project_id: project-compatibility +schema_version: 3 +minimum_writer_version: 0.3.0 +generation_id: generation-compatibility +revision: 3 +--- +# 项目历史 + +> 按时间逆序排列。 diff --git "a/testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" new file mode 100644 index 0000000..733b3a7 --- /dev/null +++ "b/testdata/contracts/migration/v3/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -0,0 +1,32 @@ +--- +id: project-overview +entity_type: project_review +project_id: project-compatibility +schema_version: 3 +minimum_writer_version: 0.3.0 +generation_id: generation-compatibility +revision: 3 +--- +# Compatibility + +## 项目目标 +Preserve + +## 当前阶段 +migration + +## 当前状态 +active + +## 下一步 +verify + +## 风险与待办 + +## 关键决策 + +## 最近验证 +2026-09-04 + +## 项目历史 +[打开完整项目历史](./项目历史.md) diff --git a/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json new file mode 100644 index 0000000..c97eb64 --- /dev/null +++ b/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json @@ -0,0 +1 @@ +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"f4f61cd8e805ed82aa25f1fbeeaeb67baa838352567409e26036b99dda3ce88c","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} \ No newline at end of file diff --git a/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/session-index.json b/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/session-index.json new file mode 100644 index 0000000..f82f344 --- /dev/null +++ b/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/session-index.json @@ -0,0 +1 @@ +{"schema_version":1,"minimum_reader_version":"0.4.0","digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","generated_at":"2026-09-04T00:00:00Z","sort_version":"started-at-desc-null-last-provider-session-v1","coverage":{"total":0,"complete":0,"partial":0,"error":0,"unprocessed":0,"source_available":0,"source_unavailable":0,"started_at_known":0,"ended_at_known":0,"usage_known":0},"sessions":[]} diff --git "a/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" "b/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" new file mode 100644 index 0000000..1648479 --- /dev/null +++ "b/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\216\206\345\217\262.md" @@ -0,0 +1,12 @@ +--- +id: project-history +entity_type: project_history +project_id: project-compatibility +schema_version: 3 +minimum_writer_version: 0.3.0 +generation_id: generation-compatibility +revision: 3 +--- +# 项目历史 + +> 按时间逆序排列。 diff --git "a/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" new file mode 100644 index 0000000..1ca95b5 --- /dev/null +++ "b/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -0,0 +1 @@ +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","revision":3,"current_state":{"goal":"Preserve","stage":"migration","status":"active","next_action":"verify","last_verification":"2026-09-04"},"timeline":[],"decisions":[],"risks":[],"open_loops":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[]} From f5c6747541d08ef880ef85f8912b52b3ba9a1da4 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 20:05:29 +0800 Subject: [PATCH 11/25] fix: add stable v4 wire rejection codes --- internal/annotation/validate.go | 2 +- internal/annotation/validate_test.go | 4 ++ internal/inspect/validate.go | 4 +- internal/inspect/validate_test.go | 13 +++-- internal/pricing/validate.go | 4 +- internal/pricing/validate_test.go | 6 +++ internal/reviewv4/codec.go | 25 +++++----- internal/reviewv4/codec_test.go | 29 +++++++++-- internal/sessionindex/validate.go | 4 +- internal/sessionindex/validate_test.go | 4 ++ internal/strictjson/codec.go | 17 ++++--- internal/strictjson/codec_test.go | 49 +++++++++++++++++++ internal/strictjson/rejection.go | 68 ++++++++++++++++++++++++++ 13 files changed, 194 insertions(+), 35 deletions(-) create mode 100644 internal/strictjson/rejection.go diff --git a/internal/annotation/validate.go b/internal/annotation/validate.go index 5c697a5..71dc602 100644 --- a/internal/annotation/validate.go +++ b/internal/annotation/validate.go @@ -89,7 +89,7 @@ func Parse(data []byte) (StoreRecord, error) { return store, err } if err := Validate(store); err != nil { - return store, err + return store, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } return store, nil } diff --git a/internal/annotation/validate_test.go b/internal/annotation/validate_test.go index d3155dd..de2fd92 100644 --- a/internal/annotation/validate_test.go +++ b/internal/annotation/validate_test.go @@ -3,6 +3,8 @@ package annotation import ( "os" "testing" + + "github.com/neomei/SessionReviewer/internal/strictjson" ) func TestParseFrozenValidFixture(t *testing.T) { @@ -28,6 +30,8 @@ func TestParseRejectsFrozenInvalidFixture(t *testing.T) { } if _, err := Parse(b); err == nil { t.Fatal("accepted frozen invalid fixture") + } else if got := strictjson.CodeOf(err); got != "wire_shape_invalid" { + t.Fatalf("rejection code = %q, want wire_shape_invalid: %v", got, err) } } diff --git a/internal/inspect/validate.go b/internal/inspect/validate.go index 292baed..e56c8bc 100644 --- a/internal/inspect/validate.go +++ b/internal/inspect/validate.go @@ -172,7 +172,7 @@ func ParseSummary(data []byte) (SessionSummary, error) { return summary, err } if err := ValidateSummary(summary); err != nil { - return summary, err + return summary, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } return summary, nil } @@ -183,7 +183,7 @@ func ParseEventPage(data []byte) (SessionEventPage, error) { return page, err } if err := ValidateEventPage(page); err != nil { - return page, err + return page, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } return page, nil } diff --git a/internal/inspect/validate_test.go b/internal/inspect/validate_test.go index f9b1e79..9ab8d6e 100644 --- a/internal/inspect/validate_test.go +++ b/internal/inspect/validate_test.go @@ -32,12 +32,13 @@ func TestValidateRejectsCoverageAdditionOverflow(t *testing.T) { func TestParsersRejectFrozenInvalidFixtures(t *testing.T) { for _, tc := range []struct { - name string - path string - parse func([]byte) error + name string + path string + wantCode string + parse func([]byte) error }{ - {name: "summary", path: "../../testdata/contracts/v4/session-summary-v1.invalid.json", parse: func(b []byte) error { _, err := ParseSummary(b); return err }}, - {name: "event page", path: "../../testdata/contracts/v4/session-event-page-v1.invalid.json", parse: func(b []byte) error { _, err := ParseEventPage(b); return err }}, + {name: "summary", path: "../../testdata/contracts/v4/session-summary-v1.invalid.json", wantCode: "wire_shape_invalid", parse: func(b []byte) error { _, err := ParseSummary(b); return err }}, + {name: "event page", path: "../../testdata/contracts/v4/session-event-page-v1.invalid.json", wantCode: "wire_contract_invalid", parse: func(b []byte) error { _, err := ParseEventPage(b); return err }}, } { t.Run(tc.name, func(t *testing.T) { b, err := os.ReadFile(tc.path) @@ -46,6 +47,8 @@ func TestParsersRejectFrozenInvalidFixtures(t *testing.T) { } if err := tc.parse(b); err == nil { t.Fatal("accepted frozen invalid fixture") + } else if got := strictjson.CodeOf(err); got != tc.wantCode { + t.Fatalf("rejection code = %q, want %s: %v", got, tc.wantCode, err) } }) } diff --git a/internal/pricing/validate.go b/internal/pricing/validate.go index 18f0b26..0e4080a 100644 --- a/internal/pricing/validate.go +++ b/internal/pricing/validate.go @@ -166,7 +166,7 @@ func Parse(data []byte) (Snapshot, error) { return snapshot, err } if err := ValidateSnapshot(snapshot); err != nil { - return snapshot, err + return snapshot, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } return snapshot, nil } @@ -187,7 +187,7 @@ func ParseSupplement(data []byte) (Supplement, error) { return supplement, err } if err := ValidateSupplement(supplement); err != nil { - return supplement, err + return supplement, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } return supplement, nil } diff --git a/internal/pricing/validate_test.go b/internal/pricing/validate_test.go index ab54a2c..a4ec888 100644 --- a/internal/pricing/validate_test.go +++ b/internal/pricing/validate_test.go @@ -4,6 +4,8 @@ import ( "math" "os" "testing" + + "github.com/neomei/SessionReviewer/internal/strictjson" ) func TestParseFrozenValidFixture(t *testing.T) { @@ -90,6 +92,8 @@ func TestParseAndRenderPricingFixtureParity(t *testing.T) { } if _, err := Parse(invalid); err == nil { t.Fatal("accepted frozen invalid fixture") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) } } @@ -114,6 +118,8 @@ func TestPricingSupplementFixtureParityAndNullMeansUnknown(t *testing.T) { } if _, err := ParseSupplement(invalid); err == nil { t.Fatal("accepted frozen invalid supplement fixture") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) } } diff --git a/internal/reviewv4/codec.go b/internal/reviewv4/codec.go index cb64ee8..32c6dc5 100644 --- a/internal/reviewv4/codec.go +++ b/internal/reviewv4/codec.go @@ -20,7 +20,7 @@ func DecodePresentation(data []byte) (Presentation, error) { return presentation, err } if err := ValidatePresentation(presentation); err != nil { - return presentation, err + return presentation, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } return presentation, nil } @@ -31,10 +31,10 @@ func DecodeLedger(data []byte) (MachineLedger, error) { return ledger, err } if err := ValidateLedger(ledger); err != nil { - return ledger, err + return ledger, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } if !isZeroSHA(ledger.SyncHashes.LedgerSHA256) && CanonicalLedgerSHA256(ledger) != ledger.SyncHashes.LedgerSHA256 { - return ledger, errors.New("machine ledger self digest mismatch") + return ledger, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("machine ledger self digest mismatch")) } return ledger, nil } @@ -44,7 +44,7 @@ func Parse(review, history, ledger []byte) (Accepted, error) { } func LoadProjection(review, history, ledger, index []byte) (Accepted, error) { if len(index) == 0 { - return Accepted{}, errors.New("session index is required") + return Accepted{}, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("session index is required")) } return parse(review, history, ledger, index) } @@ -56,8 +56,11 @@ func parse(reviewBytes, historyBytes, ledgerBytes, indexBytes []byte) (Accepted, if err != nil { return accepted, fmt.Errorf("review: %w", err) } - if len(historyBytes) > strictjson.MaxBytes || !utf8.Valid(historyBytes) { - return accepted, errors.New("history exceeds the byte limit or is not UTF-8") + if len(historyBytes) > strictjson.MaxBytes { + return accepted, strictjson.NewRejection(strictjson.CodeInputOverflow, errors.New("history exceeds the byte limit")) + } + if !utf8.Valid(historyBytes) { + return accepted, strictjson.NewRejection(strictjson.CodeInvalidUTF8, errors.New("history is not UTF-8")) } accepted.History = append([]byte(nil), historyBytes...) accepted.Ledger, err = DecodeLedger(ledgerBytes) @@ -65,13 +68,13 @@ func parse(reviewBytes, historyBytes, ledgerBytes, indexBytes []byte) (Accepted, return accepted, fmt.Errorf("ledger: %w", err) } if err := ValidateAccepted(accepted); err != nil { - return accepted, err + return accepted, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } if isZeroSHA(accepted.Ledger.SyncHashes.LedgerSHA256) { - return accepted, errors.New("machine ledger self digest is unset") + return accepted, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("machine ledger self digest is unset")) } if accepted.Ledger.ReviewSHA256 != sha256Hex(reviewBytes) || accepted.Ledger.HistorySHA256 != sha256Hex(historyBytes) { - return accepted, errors.New("review or history content hash mismatch") + return accepted, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("review or history content hash mismatch")) } if len(indexBytes) > 0 { accepted.SessionIndex, err = sessionindex.Parse(indexBytes) @@ -80,10 +83,10 @@ func parse(reviewBytes, historyBytes, ledgerBytes, indexBytes []byte) (Accepted, } index := accepted.SessionIndex if index.Digest == "sha256:"+strings.Repeat("0", 64) { - return accepted, errors.New("session index digest is unset") + return accepted, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("session index digest is unset")) } if index.ProjectID != accepted.Review.ProjectID || index.GenerationID != accepted.Review.GenerationID || index.ProjectViewDigest != accepted.Review.ProjectViewDigest || index.Digest != accepted.Ledger.SyncHashes.SessionIndexDigest { - return accepted, errors.New("session index identity, generation, or digest mismatch") + return accepted, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("session index identity, generation, or digest mismatch")) } } return accepted, nil diff --git a/internal/reviewv4/codec_test.go b/internal/reviewv4/codec_test.go index dec3300..83ca760 100644 --- a/internal/reviewv4/codec_test.go +++ b/internal/reviewv4/codec_test.go @@ -11,6 +11,7 @@ import ( "github.com/neomei/SessionReviewer/internal/pricing" "github.com/neomei/SessionReviewer/internal/sessionindex" + "github.com/neomei/SessionReviewer/internal/strictjson" ) func TestRenderFrozenValidLedgerFixture(t *testing.T) { @@ -30,17 +31,20 @@ func TestRenderFrozenValidLedgerFixture(t *testing.T) { func TestReviewParseRejectsUnknownFields(t *testing.T) { if _, err := Parse([]byte(`{"unknown":true}`), []byte(`{}`), []byte(`{}`)); err == nil { t.Fatal("accepted unknown review fields") + } else if got := strictjson.CodeOf(err); got != "wire_shape_invalid" { + t.Fatalf("rejection code = %q, want wire_shape_invalid: %v", got, err) } } func TestFrozenInvalidReviewAndLedgerFixturesAreRejected(t *testing.T) { for _, tc := range []struct { - name string - path string - fn func([]byte) error + name string + path string + wantCode string + fn func([]byte) error }{ - {name: "review", path: "../../testdata/contracts/v4/review-presentation-v4.invalid.json", fn: func(b []byte) error { _, err := DecodePresentation(b); return err }}, - {name: "ledger", path: "../../testdata/contracts/v4/machine-ledger-v4.invalid.json", fn: func(b []byte) error { _, err := DecodeLedger(b); return err }}, + {name: "review", path: "../../testdata/contracts/v4/review-presentation-v4.invalid.json", wantCode: "wire_shape_invalid", fn: func(b []byte) error { _, err := DecodePresentation(b); return err }}, + {name: "ledger", path: "../../testdata/contracts/v4/machine-ledger-v4.invalid.json", wantCode: "wire_contract_invalid", fn: func(b []byte) error { _, err := DecodeLedger(b); return err }}, } { t.Run(tc.name, func(t *testing.T) { b, err := os.ReadFile(tc.path) @@ -49,6 +53,8 @@ func TestFrozenInvalidReviewAndLedgerFixturesAreRejected(t *testing.T) { } if err := tc.fn(b); err == nil { t.Fatal("accepted frozen invalid fixture") + } else if got := strictjson.CodeOf(err); got != tc.wantCode { + t.Fatalf("rejection code = %q, want %s: %v", got, tc.wantCode, err) } }) } @@ -155,6 +161,8 @@ func TestLoadProjectionEnforcesAllIdentityAndDigestBindings(t *testing.T) { } if _, err := LoadProjection(reviewBytes, history, ledgerBytes, indexBytes); err == nil { t.Fatal("accepted mismatched projection") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) } }) } @@ -195,8 +203,15 @@ func TestLoadProjectionEnforcesAllIdentityAndDigestBindings(t *testing.T) { if accepted.SessionIndex.ProjectID != presentation.ProjectID { t.Fatal("validated index missing from Accepted") } + if _, err := Parse(reviewFixture, append(append([]byte(nil), history...), 'x'), ledgerBytes); err == nil { + t.Fatal("accepted mismatched history binding") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) + } if _, err := LoadProjection(reviewFixture, history, ledgerBytes, nil); err == nil { t.Fatal("accepted projection without required session index") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) } } @@ -224,6 +239,8 @@ func TestParseAcceptsRawMarkdownHistoryAndRejectsInvalidUTF8(t *testing.T) { ledgerBytes, _ = RenderLedger(ledger) if _, err := Parse(review, invalid, ledgerBytes); err == nil { t.Fatal("accepted invalid UTF-8 history") + } else if got := strictjson.CodeOf(err); got != "wire_invalid_utf8" { + t.Fatalf("rejection code = %q, want wire_invalid_utf8: %v", got, err) } } @@ -248,6 +265,8 @@ func TestDecodeLedgerRejectsTamperedSelfDigest(t *testing.T) { } if _, err := DecodeLedger(body); err == nil { t.Fatal("accepted tampered ledger self digest") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) } } diff --git a/internal/sessionindex/validate.go b/internal/sessionindex/validate.go index 9dbfe88..f9c50d6 100644 --- a/internal/sessionindex/validate.go +++ b/internal/sessionindex/validate.go @@ -136,10 +136,10 @@ func Parse(data []byte) (Document, error) { return document, err } if err := Validate(document); err != nil { - return document, err + return document, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } if !isZeroDigest(document.Digest) && CanonicalDigest(document) != document.Digest { - return document, errors.New("session index digest mismatch") + return document, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("session index digest mismatch")) } return document, nil } diff --git a/internal/sessionindex/validate_test.go b/internal/sessionindex/validate_test.go index 970901c..cc13e5d 100644 --- a/internal/sessionindex/validate_test.go +++ b/internal/sessionindex/validate_test.go @@ -4,6 +4,8 @@ import ( "math" "os" "testing" + + "github.com/neomei/SessionReviewer/internal/strictjson" ) func TestParseFrozenValidFixture(t *testing.T) { @@ -57,6 +59,8 @@ func TestParseRejectsFrozenInvalidFixture(t *testing.T) { } if _, err := Parse(b); err == nil { t.Fatal("accepted frozen invalid fixture") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) } } diff --git a/internal/strictjson/codec.go b/internal/strictjson/codec.go index a56d689..d26a40f 100644 --- a/internal/strictjson/codec.go +++ b/internal/strictjson/codec.go @@ -17,32 +17,35 @@ const MaxBytes = 64 << 20 func Decode(data []byte, dst any) error { if len(data) > MaxBytes { - return fmt.Errorf("json exceeds %d bytes", MaxBytes) + return NewRejection(CodeInputOverflow, fmt.Errorf("json exceeds %d bytes", MaxBytes)) } if !utf8.Valid(data) { - return errors.New("json is not valid UTF-8") + return NewRejection(CodeInvalidUTF8, errors.New("json is not valid UTF-8")) } dec := json.NewDecoder(bytes.NewReader(data)) dec.UseNumber() if err := scanValue(dec); err != nil { - return fmt.Errorf("invalid JSON: %w", err) + return NewRejection(CodeJSONInvalid, fmt.Errorf("invalid JSON: %w", err)) } if err := expectEOF(dec); err != nil { - return err + return NewRejection(CodeJSONInvalid, err) } var raw any rawDecoder := json.NewDecoder(bytes.NewReader(data)) rawDecoder.UseNumber() if err := rawDecoder.Decode(&raw); err != nil { - return fmt.Errorf("decode JSON shape: %w", err) + return NewRejection(CodeJSONInvalid, fmt.Errorf("decode JSON shape: %w", err)) } dec = json.NewDecoder(bytes.NewReader(data)) dec.DisallowUnknownFields() dec.UseNumber() if err := dec.Decode(dst); err != nil { - return fmt.Errorf("decode JSON: %w", err) + return NewRejection(CodeShapeInvalid, fmt.Errorf("decode JSON: %w", err)) + } + if err := validateWireShape(raw, reflect.ValueOf(dst)); err != nil { + return NewRejection(CodeShapeInvalid, err) } - return validateWireShape(raw, reflect.ValueOf(dst)) + return nil } func Encode(v any) ([]byte, error) { diff --git a/internal/strictjson/codec_test.go b/internal/strictjson/codec_test.go index 464cffe..3b6ac20 100644 --- a/internal/strictjson/codec_test.go +++ b/internal/strictjson/codec_test.go @@ -2,10 +2,59 @@ package strictjson import ( "bytes" + "errors" "strings" "testing" ) +func TestDecodeReturnsStableRejectionCodes(t *testing.T) { + type wire struct { + Required string `json:"required" required:"true"` + } + tests := []struct { + name string + body []byte + want string + }{ + {name: "bounded input overflow", body: bytes.Repeat([]byte{' '}, MaxBytes+1), want: "wire_input_overflow"}, + {name: "invalid UTF-8", body: []byte{'{', '"', 'x', '"', ':', '"', 0xff, '"', '}'}, want: "wire_invalid_utf8"}, + {name: "malformed JSON", body: []byte(`{"required":`), want: "wire_json_invalid"}, + {name: "duplicate key", body: []byte(`{"required":"first","required":"second"}`), want: "wire_json_invalid"}, + {name: "trailing JSON value", body: []byte(`{"required":"ok"} {"required":"extra"}`), want: "wire_json_invalid"}, + {name: "trailing garbage", body: []byte(`{"required":"ok"} garbage`), want: "wire_json_invalid"}, + {name: "unknown field", body: []byte(`{"required":"ok","unknown":true}`), want: "wire_shape_invalid"}, + {name: "missing field", body: []byte(`{}`), want: "wire_shape_invalid"}, + {name: "type mismatch", body: []byte(`{"required":1}`), want: "wire_shape_invalid"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var got wire + err := Decode(tc.body, &got) + if code := CodeOf(err); code != tc.want { + t.Fatalf("rejection code = %q, want %q: %v", code, tc.want, err) + } + var rejection *RejectionError + if !errors.As(err, &rejection) { + t.Fatalf("error is not a typed rejection: %T %v", err, err) + } + }) + } +} + +func TestRejectionErrorPreservesCause(t *testing.T) { + cause := errors.New("semantic detail") + err := NewRejection(CodeContractInvalid, cause) + if got := CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q", got) + } + if err.Error() != cause.Error() { + t.Fatalf("human message changed: %q", err.Error()) + } + if !errors.Is(err, cause) { + t.Fatal("typed rejection does not unwrap to its cause") + } +} + func TestDecodeRejectsDuplicateNestedKeys(t *testing.T) { var v map[string]any if err := Decode([]byte(`{"a":{"x":1,"x":2}}`), &v); err == nil { diff --git a/internal/strictjson/rejection.go b/internal/strictjson/rejection.go new file mode 100644 index 0000000..f4db592 --- /dev/null +++ b/internal/strictjson/rejection.go @@ -0,0 +1,68 @@ +package strictjson + +import "errors" + +// RejectionCode is the closed compatibility taxonomy for rejecting untrusted +// v4 wire input. Additions or value changes require a coordinated contract +// revision; human-readable error messages are deliberately not stable API. +type RejectionCode string + +const ( + // CodeInputOverflow means the bounded input byte limit was exceeded. + CodeInputOverflow RejectionCode = "wire_input_overflow" + // CodeInvalidUTF8 means the input bytes are not valid UTF-8. + CodeInvalidUTF8 RejectionCode = "wire_invalid_utf8" + // CodeJSONInvalid covers malformed JSON, duplicate keys, and trailing data. + CodeJSONInvalid RejectionCode = "wire_json_invalid" + // CodeShapeInvalid covers exact-field, required-field, and JSON type failures. + CodeShapeInvalid RejectionCode = "wire_shape_invalid" + // CodeContractInvalid covers post-decode semantic and binding invariants. + CodeContractInvalid RejectionCode = "wire_contract_invalid" +) + +// RejectionError attaches a stable rejection code while retaining the +// original diagnostic as its error text and unwrap target. +type RejectionError struct { + code RejectionCode + cause error +} + +func (e *RejectionError) Error() string { return e.cause.Error() } +func (e *RejectionError) Unwrap() error { return e.cause } +func (e *RejectionError) Code() string { return string(e.code) } + +// NewRejection attaches code to cause. An existing rejection is preserved so +// callers wrapping a lower-level parser do not erase the most specific code. +func NewRejection(code RejectionCode, cause error) error { + if cause == nil { + return nil + } + var existing *RejectionError + if errors.As(cause, &existing) { + return cause + } + if !code.valid() { + panic("strictjson: unknown rejection code") + } + return &RejectionError{code: code, cause: cause} +} + +// CodeOf returns the stable wire rejection code carried by err, including +// through errors-compatible wrapping. It returns an empty string for errors +// outside the untrusted v4 wire boundary. +func CodeOf(err error) string { + var rejection *RejectionError + if errors.As(err, &rejection) { + return rejection.Code() + } + return "" +} + +func (code RejectionCode) valid() bool { + switch code { + case CodeInputOverflow, CodeInvalidUTF8, CodeJSONInvalid, CodeShapeInvalid, CodeContractInvalid: + return true + default: + return false + } +} From ab653ac142c8fb1261bf160e6e7c35820cea9ddd Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 20:19:06 +0800 Subject: [PATCH 12/25] fix: mirror stable wire rejection codes in Obsidian --- obsidian-plugin/src/data/contracts-v4.ts | 146 +++++++++++++++++---- obsidian-plugin/tests/contracts-v4.test.ts | 105 +++++++++++++-- 2 files changed, 213 insertions(+), 38 deletions(-) diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index dc6cafe..001e6b8 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -48,7 +48,42 @@ const PRICE_DIMENSIONS = ["input", "cached_input", "cache_write_input", "output" type JsonObject = Record; +export type WireRejectionCode = + | "wire_input_overflow" + | "wire_invalid_utf8" + | "wire_json_invalid" + | "wire_shape_invalid" + | "wire_contract_invalid"; + +export class WireRejectionError extends Error { + public readonly cause: unknown; + public readonly code: WireRejectionCode; + + public constructor(code: WireRejectionCode, cause: unknown) { + super(message(cause)); + this.name = "WireRejectionError"; + this.code = code; + this.cause = cause; + } +} + +export function codeOf(error: unknown): WireRejectionCode | undefined { + const seen = new Set(); + let current = error; + while ((typeof current === "object" && current !== null) || typeof current === "function") { + if (current instanceof WireRejectionError) return current.code; + if (seen.has(current)) return undefined; + seen.add(current); + current = (current as { cause?: unknown }).cause; + } + return undefined; +} + export function parseReviewPresentationV4(source: string): ReviewPresentationV4 { + return atWireBoundary(() => parseReviewPresentationDocument(source)); +} + +function parseReviewPresentationDocument(source: string): ReviewPresentationV4 { const row = documentObject(source, "review presentation"); exact(row, "$", [ "schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", @@ -120,6 +155,10 @@ export function parseReviewPresentationV4(source: string): ReviewPresentationV4 } export function parseMachineLedgerV4(source: string): MachineLedgerV4 { + return atWireBoundary(() => parseMachineLedgerDocument(source)); +} + +function parseMachineLedgerDocument(source: string): MachineLedgerV4 { const row = documentObject(source, "machine ledger"); exact(row, "$", [ "schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", @@ -187,6 +226,10 @@ export function parseMachineLedgerV4(source: string): MachineLedgerV4 { } export function parseSessionIndexV1(source: string): SessionIndexV1 { + return atWireBoundary(() => parseSessionIndexDocument(source)); +} + +function parseSessionIndexDocument(source: string): SessionIndexV1 { const row = documentObject(source, "session index"); exact(row, "$", [ "schema_version", "minimum_reader_version", "digest", "project_id", "generation_id", "project_view_digest", @@ -250,6 +293,10 @@ export function parseSessionIndexV1(source: string): SessionIndexV1 { } export function parseSessionSummaryV1(source: string): SessionSummaryV1 { + return atWireBoundary(() => parseSessionSummaryDocument(source)); +} + +function parseSessionSummaryDocument(source: string): SessionSummaryV1 { const row = documentObject(source, "session summary"); exact(row, "$", [ "schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "generation_id", @@ -268,6 +315,10 @@ export function parseSessionSummaryV1(source: string): SessionSummaryV1 { } export function parseSessionEventPageV1(source: string): SessionEventPageV1 { + return atWireBoundary(() => parseSessionEventPageDocument(source)); +} + +function parseSessionEventPageDocument(source: string): SessionEventPageV1 { const row = documentObject(source, "session event page"); exact(row, "$", [ "schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "generation_id", @@ -300,6 +351,10 @@ export function parseSessionEventPageV1(source: string): SessionEventPageV1 { } export function parseAgentAnnotationV1(source: string): AgentAnnotationV1 { + return atWireBoundary(() => parseAgentAnnotationDocument(source)); +} + +function parseAgentAnnotationDocument(source: string): AgentAnnotationV1 { const row = documentObject(source, "agent annotation"); exact(row, "$", ["schema_version", "minimum_reader_version", "project_id", "annotations", "extraction_runs"]); constant(row.schema_version, 1, "$.schema_version"); @@ -328,10 +383,14 @@ export function parseCandidateListV1(source: string): CandidateListV1 { } export function parsePricingSnapshotV1(source: string): PricingSnapshotV1 { - return validatePricingSnapshot(documentObject(source, "pricing snapshot"), "$" ); + return atWireBoundary(() => validatePricingSnapshot(documentObject(source, "pricing snapshot"), "$")); } export function parsePricingSupplementV1(source: string): PricingSupplementV1 { + return atWireBoundary(() => parsePricingSupplementDocument(source)); +} + +function parsePricingSupplementDocument(source: string): PricingSupplementV1 { const row = documentObject(source, "pricing supplement"); exact(row, "$", [ "schema_version", "minimum_reader_version", "project_id", "provider", "session_id", "usage_record_digest", @@ -360,12 +419,14 @@ export function parsePricingSupplementV1(source: string): PricingSupplementV1 { } export function assertSnapshotBindings(ledger: MachineLedgerV4, index: SessionIndexV1): void { - if (index.digest === ZERO_DIGEST) throw new Error("session index digest is unset"); - if (ledger.sync_hashes.ledger_sha256 === ZERO_SHA256) throw new Error("machine ledger self hash is unset"); - if (ledger.project_id !== index.project_id || ledger.generation_id !== index.generation_id || - ledger.project_view_digest !== index.project_view_digest || ledger.sync_hashes.session_index_digest !== index.digest) { - throw new Error("ledger and session index snapshot binding mismatch"); - } + atWireBoundary(() => { + if (index.digest === ZERO_DIGEST) throw new Error("session index digest is unset"); + if (ledger.sync_hashes.ledger_sha256 === ZERO_SHA256) throw new Error("machine ledger self hash is unset"); + if (ledger.project_id !== index.project_id || ledger.generation_id !== index.generation_id || + ledger.project_view_digest !== index.project_view_digest || ledger.sync_hashes.session_index_digest !== index.digest) { + throw new Error("ledger and session index snapshot binding mismatch"); + } + }); } function parseTimeline(value: unknown, path: string, generationID: string): TimelineEntryV4 { @@ -855,37 +916,62 @@ function parseLineCosts(value: unknown, path: string): PricingLineCostsV1 { return row as unknown as PricingLineCostsV1; } +function atWireBoundary(action: () => T): T { + try { + return action(); + } catch (error) { + throw rejection("wire_contract_invalid", error); + } +} + +function rejection(code: WireRejectionCode, cause: unknown): WireRejectionError { + if (cause instanceof WireRejectionError) return cause; + return new WireRejectionError(codeOf(cause) ?? code, cause); +} + +function reject(code: WireRejectionCode, detail: string): never { + throw new WireRejectionError(code, new Error(detail)); +} + function documentObject(source: string, kind: string): JsonObject { - assertValidUnicode(source, "JSON source"); const bytes = Buffer.byteLength(source, "utf8"); - if (bytes > MAX_JSON_BYTES) throw new Error(`${kind} exceeds ${MAX_JSON_BYTES} bytes`); - rejectDuplicateJsonKeys(source); + if (bytes > MAX_JSON_BYTES) reject("wire_input_overflow", `${kind} exceeds ${MAX_JSON_BYTES} bytes`); + assertValidUnicode(source, "JSON source"); + try { + rejectDuplicateJsonKeys(source); + } catch (error) { + throw rejection("wire_json_invalid", error); + } let value: unknown; try { value = JSON.parse(source); } catch (error) { - throw new Error(`decode ${kind}: ${message(error)}`); + throw rejection("wire_json_invalid", new Error(`decode ${kind}: ${message(error)}`)); } assertJsonUnicode(value, "$", new Set()); return object(value, "$" ); } function object(value: unknown, path: string): JsonObject { - if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`); + if (typeof value !== "object" || value === null || Array.isArray(value)) { + reject("wire_shape_invalid", `${path} must be an object`); + } return value as JsonObject; } function exact(value: JsonObject, path: string, allowed: readonly string[], required: readonly string[] = allowed): void { for (const key of Object.keys(value)) { - if (!allowed.includes(key)) throw new Error(`unknown exact JSON object key "${key}" at ${path}`); + if (!allowed.includes(key)) reject("wire_shape_invalid", `unknown exact JSON object key "${key}" at ${path}`); } for (const key of required) { - if (!Object.prototype.hasOwnProperty.call(value, key)) throw new Error(`missing required JSON object key "${key}" at ${path}`); + if (!Object.prototype.hasOwnProperty.call(value, key)) { + reject("wire_shape_invalid", `missing required JSON object key "${key}" at ${path}`); + } } } function boundedArray(value: unknown, path: string, maximum: number): unknown[] { - if (!Array.isArray(value)) throw new Error(`${path} must be an array`); + if (!Array.isArray(value)) reject("wire_shape_invalid", `${path} must be an array`); if (value.length > maximum) throw new Error(`${path} exceeds ${maximum} items`); return value; } @@ -905,7 +991,7 @@ function idArray(value: unknown, path: string, maximum: number, unique = false): } function text(value: unknown, path: string, maximum: number, nonempty = false): string { - if (typeof value !== "string") throw new Error(`${path} must be a string`); + if (typeof value !== "string") reject("wire_shape_invalid", `${path} must be a string`); if (nonempty && value.length === 0) throw new Error(`${path} must not be empty`); if (Buffer.byteLength(value, "utf8") > maximum) throw new Error(`${path} exceeds ${maximum} UTF-8 bytes`); return value; @@ -923,7 +1009,8 @@ function id(value: unknown, path: string): string { } function digest(value: unknown, path: string): string { - if (typeof value !== "string" || !DIGEST.test(value)) throw new Error(`${path} must be a sha256 digest`); + if (typeof value !== "string") reject("wire_shape_invalid", `${path} must be a string`); + if (!DIGEST.test(value)) throw new Error(`${path} must be a sha256 digest`); return value; } @@ -933,12 +1020,14 @@ function nullableDigest(value: unknown, path: string): string | null { } function sha256(value: unknown, path: string): string { - if (typeof value !== "string" || !SHA256.test(value)) throw new Error(`${path} must be a lowercase SHA-256 value`); + if (typeof value !== "string") reject("wire_shape_invalid", `${path} must be a string`); + if (!SHA256.test(value)) throw new Error(`${path} must be a lowercase SHA-256 value`); return value; } function integer(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new Error(`${path} must be a safe integer`); + if (typeof value !== "number") reject("wire_shape_invalid", `${path} must be a number`); + if (!Number.isSafeInteger(value)) throw new Error(`${path} must be a safe integer`); if (value < 0) throw new Error(`${path} must be nonnegative`); return value; } @@ -955,7 +1044,8 @@ function nullableInteger(value: unknown, path: string): number | null { } function money(value: unknown, path: string): number { - if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { + if (typeof value !== "number") reject("wire_shape_invalid", `${path} must be a number`); + if (!Number.isFinite(value) || value < 0) { throw new Error(`${path} must be finite and nonnegative`); } return value; @@ -967,11 +1057,12 @@ function nullableMoney(value: unknown, path: string): number | null { } function boolean(value: unknown, path: string): boolean { - if (typeof value !== "boolean") throw new Error(`${path} must be a boolean`); + if (typeof value !== "boolean") reject("wire_shape_invalid", `${path} must be a boolean`); return value; } function constant(value: unknown, expected: unknown, path: string): void { + if (typeof value !== typeof expected) reject("wire_shape_invalid", `${path} has the wrong JSON type`); if (value !== expected) throw new Error(`${path} must equal ${String(expected)}`); } @@ -980,7 +1071,8 @@ function version(value: unknown, path: string): void { } function oneOf(value: unknown, path: string, allowed: readonly T[]): T { - if (typeof value !== "string" || !allowed.includes(value as T)) throw new Error(`${path} is not in the closed enum`); + if (typeof value !== "string") reject("wire_shape_invalid", `${path} must be a string`); + if (!allowed.includes(value as T)) throw new Error(`${path} is not in the closed enum`); return value as T; } @@ -1294,10 +1386,12 @@ function assertValidUnicode(value: string, path: string): void { const code = value.charCodeAt(index); if (code >= 0xd800 && code <= 0xdbff) { const next = value.charCodeAt(index + 1); - if (!(next >= 0xdc00 && next <= 0xdfff)) throw new Error(`${path} contains an unpaired Unicode surrogate`); + if (!(next >= 0xdc00 && next <= 0xdfff)) { + reject("wire_invalid_utf8", `${path} contains an unpaired Unicode surrogate`); + } index += 1; } else if (code >= 0xdc00 && code <= 0xdfff) { - throw new Error(`${path} contains an unpaired Unicode surrogate`); + reject("wire_invalid_utf8", `${path} contains an unpaired Unicode surrogate`); } } } @@ -1341,7 +1435,9 @@ function rejectDuplicateJsonKeys(source: string): void { assertValidUnicode(decoded, "JSON string"); return decoded; } catch (error) { - throw new Error(`decode JSON: malformed string: ${message(error)}`); + throw rejection("wire_json_invalid", error instanceof WireRejectionError + ? error + : new Error(`decode JSON: malformed string: ${message(error)}`)); } } cursor += 1; diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index ece4642..fe0ea25 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { assertSnapshotBindings, + codeOf, parseAgentAnnotationV1, parseCandidateListV1, parseMachineLedgerV4, @@ -12,7 +13,9 @@ import { parseReviewPresentationV4, parseSessionEventPageV1, parseSessionIndexV1, - parseSessionSummaryV1 + parseSessionSummaryV1, + WireRejectionError, + type WireRejectionCode } from "../src/data/contracts-v4"; const here = dirname(fileURLToPath(import.meta.url)); @@ -24,17 +27,31 @@ const sharedFixture = (name: string): Promise => type JsonObject = Record; type Parser = (source: string) => unknown; -const contracts: ReadonlyArray> = [ - { name: "review-presentation-v4", parser: parseReviewPresentationV4 }, - { name: "machine-ledger-v4", parser: parseMachineLedgerV4 }, - { name: "session-index-v1", parser: parseSessionIndexV1 }, - { name: "session-summary-v1", parser: parseSessionSummaryV1 }, - { name: "session-event-page-v1", parser: parseSessionEventPageV1 }, - { name: "agent-annotation-v1", parser: parseAgentAnnotationV1 }, - { name: "pricing-snapshot-v1", parser: parsePricingSnapshotV1 }, - { name: "pricing-supplement-v1", parser: parsePricingSupplementV1 } +const contracts: ReadonlyArray> = [ + { name: "review-presentation-v4", parser: parseReviewPresentationV4, invalidCode: "wire_shape_invalid" }, + { name: "machine-ledger-v4", parser: parseMachineLedgerV4, invalidCode: "wire_contract_invalid" }, + { name: "session-index-v1", parser: parseSessionIndexV1, invalidCode: "wire_contract_invalid" }, + { name: "session-summary-v1", parser: parseSessionSummaryV1, invalidCode: "wire_shape_invalid" }, + { name: "session-event-page-v1", parser: parseSessionEventPageV1, invalidCode: "wire_contract_invalid" }, + { name: "agent-annotation-v1", parser: parseAgentAnnotationV1, invalidCode: "wire_shape_invalid" }, + { name: "pricing-snapshot-v1", parser: parsePricingSnapshotV1, invalidCode: "wire_contract_invalid" }, + { name: "pricing-supplement-v1", parser: parsePricingSupplementV1, invalidCode: "wire_contract_invalid" } ]; +function captureRejection(action: () => unknown): WireRejectionError { + try { + action(); + } catch (error) { + expect(error).toBeInstanceOf(WireRejectionError); + return error as WireRejectionError; + } + throw new Error("expected parser rejection"); +} + async function fixtureObject(name: string): Promise { return JSON.parse(await pluginFixture(name)) as JsonObject; } @@ -82,9 +99,9 @@ describe("frozen v4 contract fixture parity", () => { expect(() => contract.parser(source)).not.toThrow(); }); - it(`rejects the frozen ${contract.name} invalid fixture through its production parser`, async () => { + it(`rejects the frozen ${contract.name} invalid fixture with its Go-compatible code`, async () => { const source = await pluginFixture(`${contract.name}.invalid.json`); - expect(() => contract.parser(source)).toThrow(); + expect(codeOf(captureRejection(() => contract.parser(source)))).toBe(contract.invalidCode); }); it(`keeps both ${contract.name} fixtures byte-identical to the shared Go fixtures`, async () => { @@ -101,6 +118,66 @@ describe("frozen v4 contract fixture parity", () => { }); }); +describe("stable wire rejection codes", () => { + it("classifies every JavaScript-representable wire rejection phase", async () => { + const annotation = await pluginFixture("agent-annotation-v1.valid.json"); + const annotationObject = JSON.parse(annotation) as JsonObject; + const unknown = { ...annotationObject, unknown: true }; + const invalidID = { ...annotationObject, project_id: "invalid project id" }; + const missing = { ...annotationObject }; + delete missing.project_id; + const tooManyAnnotations = { ...annotationObject, annotations: Array(65537).fill(null) }; + const review = await fixtureObject("review-presentation-v4.valid.json"); + review.revision = -1; + const pricing = await pluginFixture("pricing-snapshot-v1.invalid.json"); + const cases: ReadonlyArray> = [ + { name: "input overflow", source: `"${"a".repeat((64 << 20) + 1)}"`, code: "wire_input_overflow" }, + { name: "literal unpaired surrogate", source: annotation.replace("project-p", "\ud800"), code: "wire_invalid_utf8" }, + { name: "escaped unpaired surrogate", source: annotation.replace("project-p", "\\ud800"), code: "wire_invalid_utf8" }, + { name: "malformed JSON", source: "{", code: "wire_json_invalid" }, + { name: "duplicate key", source: '{"schema_version":1,"schema_version":1}', code: "wire_json_invalid" }, + { name: "trailing JSON value", source: `${annotation} {}`, code: "wire_json_invalid" }, + { name: "wrong root container", source: "[]", code: "wire_shape_invalid" }, + { name: "unknown exact key", source: JSON.stringify(unknown), code: "wire_shape_invalid" }, + { name: "missing required key", source: JSON.stringify(missing), code: "wire_shape_invalid" }, + { name: "null in required scalar", source: JSON.stringify({ ...annotationObject, project_id: null }), code: "wire_shape_invalid" }, + { name: "wrong scalar type", source: JSON.stringify({ ...annotationObject, project_id: 7 }), code: "wire_shape_invalid" }, + { name: "correctly typed invalid format", source: JSON.stringify(invalidID), code: "wire_contract_invalid" }, + { name: "scalar byte limit", source: JSON.stringify({ ...annotationObject, project_id: "a".repeat(257) }), code: "wire_contract_invalid" }, + { name: "array item limit", source: JSON.stringify(tooManyAnnotations), code: "wire_contract_invalid" }, + { name: "numeric range", source: JSON.stringify(review), code: "wire_contract_invalid", parser: parseReviewPresentationV4 }, + { name: "closed enum", source: pricing, code: "wire_contract_invalid", parser: parsePricingSnapshotV1 } + ]; + for (const testCase of cases) { + expect(codeOf(captureRejection(() => (testCase.parser ?? parseAgentAnnotationV1)(testCase.source))), testCase.name) + .toBe(testCase.code); + } + }); + + it("preserves the production diagnostic message and cause", async () => { + const annotation = await fixtureObject("agent-annotation-v1.valid.json"); + annotation.project_id = "invalid project id"; + const rejection = captureRejection(() => parseAgentAnnotationV1(JSON.stringify(annotation))); + expect(rejection.cause).toBeInstanceOf(Error); + expect(rejection.message).toBe((rejection.cause as Error).message); + expect(codeOf(rejection)).toBe("wire_contract_invalid"); + }); + + it("preserves a nested specific code and ignores unrelated errors", async () => { + const source = await pluginFixture("agent-annotation-v1.valid.json"); + const malformed = source.replace("project-p", "\\ud800"); + const rejection = captureRejection(() => parseCandidateListV1(malformed)); + expect(codeOf(rejection)).toBe("wire_invalid_utf8"); + expect(codeOf(new Error("ordinary failure"))).toBeUndefined(); + expect(codeOf("not an error")).toBeUndefined(); + }); +}); + describe("strict JSON boundary", () => { it("rejects duplicate keys at nested object depth", () => { const source = '{"schema_version":1,"minimum_reader_version":"0.4.0","project_id":"p","annotations":[],"extraction_runs":[{"run_id":"a","run_id":"b"}]}'; @@ -205,7 +282,9 @@ describe("session contracts", () => { } const wrongDigest = clone(index); wrongDigest.digest = `sha256:${"9".repeat(64)}`; - expect(() => assertSnapshotBindings(ledger, wrongDigest)).toThrow(/mismatch|binding/i); + const rejection = captureRejection(() => assertSnapshotBindings(ledger, wrongDigest)); + expect(rejection.message).toMatch(/mismatch|binding/i); + expect(codeOf(rejection)).toBe("wire_contract_invalid"); }); it("rejects placeholder digests at the accepted snapshot binding boundary", async () => { From 3e8337e98a21b7cdc7a2fed0152008f4f79db87d Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 22:09:04 +0800 Subject: [PATCH 13/25] fix: preserve JSON rejection causes --- obsidian-plugin/src/data/contracts-v4.ts | 10 ++++++++-- obsidian-plugin/tests/contracts-v4.test.ts | 13 +++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index 001e6b8..55ad9d7 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -933,6 +933,12 @@ function reject(code: WireRejectionCode, detail: string): never { throw new WireRejectionError(code, new Error(detail)); } +function contextualError(detail: string, cause: unknown): Error { + const error = new Error(detail); + Object.defineProperty(error, "cause", { value: cause, writable: true, configurable: true }); + return error; +} + function documentObject(source: string, kind: string): JsonObject { const bytes = Buffer.byteLength(source, "utf8"); if (bytes > MAX_JSON_BYTES) reject("wire_input_overflow", `${kind} exceeds ${MAX_JSON_BYTES} bytes`); @@ -946,7 +952,7 @@ function documentObject(source: string, kind: string): JsonObject { try { value = JSON.parse(source); } catch (error) { - throw rejection("wire_json_invalid", new Error(`decode ${kind}: ${message(error)}`)); + throw rejection("wire_json_invalid", contextualError(`decode ${kind}: ${message(error)}`, error)); } assertJsonUnicode(value, "$", new Set()); return object(value, "$" ); @@ -1437,7 +1443,7 @@ function rejectDuplicateJsonKeys(source: string): void { } catch (error) { throw rejection("wire_json_invalid", error instanceof WireRejectionError ? error - : new Error(`decode JSON: malformed string: ${message(error)}`)); + : contextualError(`decode JSON: malformed string: ${message(error)}`, error)); } } cursor += 1; diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index fe0ea25..1368910 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -176,6 +176,19 @@ describe("stable wire rejection codes", () => { expect(codeOf(new Error("ordinary failure"))).toBeUndefined(); expect(codeOf("not an error")).toBeUndefined(); }); + + it("preserves the native parser error behind malformed JSON string context", () => { + const rejection = captureRejection(() => parseAgentAnnotationV1('{"schema_version":"\\q"}')); + expect(codeOf(rejection)).toBe("wire_json_invalid"); + expect(rejection.message).toMatch(/decode JSON: malformed string/i); + const causes: unknown[] = []; + let current: unknown = rejection; + while (typeof current === "object" && current !== null && !causes.includes(current)) { + causes.push(current); + current = (current as { cause?: unknown }).cause; + } + expect(causes.some((cause) => cause instanceof SyntaxError)).toBe(true); + }); }); describe("strict JSON boundary", () => { From e4e805b9201d8f731aacca516e60b68b2eee6ef3 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Fri, 4 Sep 2026 22:25:59 +0800 Subject: [PATCH 14/25] docs: record Gate 0 verification evidence --- docs/session-review/gate-0-evidence.md | 103 ++++++++++++++++++ ...idian-project-context-navigation-design.md | 2 +- 2 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 docs/session-review/gate-0-evidence.md diff --git a/docs/session-review/gate-0-evidence.md b/docs/session-review/gate-0-evidence.md new file mode 100644 index 0000000..4255bb9 --- /dev/null +++ b/docs/session-review/gate-0-evidence.md @@ -0,0 +1,103 @@ +# Obsidian 项目上下文 v4:Gate 0 验收证据 + +## 结论 + +**LOCAL COMPLETE / WINDOWS CI PENDING** + +Gate 0 在当前 macOS 工作树的实现与合同验收均通过。由于审计提交尚未推送且没有 Pull Request,`.github/workflows/ci.yml` 中 `windows-x64` / `windows-latest` 原生执行证据尚不存在。因此本记录不宣称 Gate 0 整体完成,也不以 Windows 交叉编译代替原生 CI。 + +## 审计对象 + +- 分支:`codex/obsidian-context-v4` +- 实现提交:`3e8337e98a21b7cdc7a2fed0152008f4f79db87d` +- 合并基准:`ea5b1ba950cf03ecfa26353873f10c9aeeb1ffa4` (`origin/main`, tag `0.3.5`) +- 开始审计时 `git status --short`:无输出,工作树干净 +- 本地环境:`Darwin arm64`,`go1.26.5 darwin/arm64`,Node `v24.18.0`,npm `11.16.0` + +## 完整本地门禁 + +| 命令 | 结果 | 证据 | +|---|---|---| +| `go test -p 1 -timeout 5m -count=1 ./...` | PASS | `go list ./...` 共 55 个 package;最慢的可见 package 为 `internal/scan` 130.429s,其次为 `internal/reviewjob` 72.177s,均低于每个测试二进制的 5 分钟超时;`test/zerotoken` 53.044s 收尾 | +| `go vet ./...` | PASS | exit 0,无输出 | +| `go mod tidy -diff` | PASS | exit 0,无 diff | +| `git diff --check` | PASS | exit 0,无输出 | +| `cd obsidian-plugin && npm run check` | PASS | lint 通过;17/17 test files、111/111 tests;TypeScript typecheck 和 production bundle 通过 | + +本地完整 Go 门禁使用串行 `-p 1`,与 Gate 0 ledger 中已记录的 macOS 文件系统 I/O 争用裁决一致。CI 仍保持原生并行命令。 + +## 八组 fixture 与稳定拒绝码 + +架构级 schema fixture 门禁: + +```text +go test ./internal/memory -run '^TestV4ContractFixtures$' -count=1 -v +``` + +结果:PASS;1 个父测试和 8/8 个命名子测试通过,每组 valid fixture 被接受,invalid fixture 被拒绝。 + +Go 生产解析器稳定码矩阵门禁: + +```text +go test ./internal/reviewv4 ./internal/sessionindex ./internal/inspect ./internal/annotation ./internal/pricing -run 'Test(FrozenInvalidReviewAndLedgerFixturesAreRejected|ParseRejectsFrozenInvalidFixture|ParsersRejectFrozenInvalidFixtures|ParseAndRenderPricingFixtureParity|PricingSupplementFixtureParityAndNullMeansUnknown)$' -count=1 -v +``` + +结果:PASS;8/8 invalid fixtures 通过生产解析入口返回预期的机器可比较错误码。 + +TypeScript 精确稳定码矩阵: + +```text +cd obsidian-plugin +npx vitest run tests/contracts-v4.test.ts -t 'rejects the frozen .* invalid fixture with its Go-compatible code' +``` + +结果:PASS;8/8 矩阵测试通过,39 个非矩阵测试按过滤器跳过。同一文件的完整命令 `npx vitest run tests/contracts-v4.test.ts` 也通过 47/47。 + +| 合同 | valid fixture | invalid fixture | Go / TypeScript 预期拒绝码 | +|---|---|---|---| +| review-presentation-v4 | `review-presentation-v4.valid.json` | `review-presentation-v4.invalid.json` | `wire_shape_invalid` | +| machine-ledger-v4 | `machine-ledger-v4.valid.json` | `machine-ledger-v4.invalid.json` | `wire_contract_invalid` | +| session-index-v1 | `session-index-v1.valid.json` | `session-index-v1.invalid.json` | `wire_contract_invalid` | +| session-summary-v1 | `session-summary-v1.valid.json` | `session-summary-v1.invalid.json` | `wire_shape_invalid` | +| session-event-page-v1 | `session-event-page-v1.valid.json` | `session-event-page-v1.invalid.json` | `wire_contract_invalid` | +| agent-annotation-v1 | `agent-annotation-v1.valid.json` | `agent-annotation-v1.invalid.json` | `wire_shape_invalid` | +| pricing-snapshot-v1 | `pricing-snapshot-v1.valid.json` | `pricing-snapshot-v1.invalid.json` | `wire_contract_invalid` | +| pricing-supplement-v1 | `pricing-supplement-v1.valid.json` | `pricing-supplement-v1.invalid.json` | `wire_contract_invalid` | + +拒绝码属于封闭的五类合同:`wire_input_overflow`、`wire_invalid_utf8`、`wire_json_invalid`、`wire_shape_invalid`、`wire_contract_invalid`。Go 和 TypeScript 都保留 cause 链,调用方无需比较可变的人类错误文案。 + +## Fixture 字节一致性 + +独立遍历 `testdata/contracts/v4/*.json`,对同名 `obsidian-plugin/tests/fixtures/v4/*.json` 执行 `cmp`。结果为 16/16 字节完全一致。此门禁同时覆盖上表 8 个 valid 和 8 个 invalid fixture。 + +## 禁止占位符检查 + +按计划原样执行: + +```bash +rg -n $'\x54\x42\x44|\x54\x4f\x44\x4f|\x46\x49\x58\x4d\x45|\x69\x6d\x70\x6c\x65\x6d\x65\x6e\x74\x20\x6c\x61\x74\x65\x72|\x66\x69\x6c\x6c\x20\x69\x6e\x20\x64\x65\x74\x61\x69\x6c\x73|\x68\x61\x6e\x64\x6c\x65\x20\x65\x64\x67\x65\x20\x63\x61\x73\x65\x73|\x73\x69\x6d\x69\x6c\x61\x72\x20\x74\x6f' schemas internal/reviewv4 internal/sessionindex internal/inspect internal/annotation internal/pricing obsidian-plugin/src/contracts/review-v4.ts +``` + +结果:无输出,`rg` exit 1,表示要求的路径内无命中。 + +## Windows 证据状态 + +`.github/workflows/ci.yml` 的 `test` job 包含: + +```text +name: windows-x64 +os: windows-latest +``` + +该原生 job 会执行 Obsidian 门禁、`go test ./...`、Windows 替换压测、race/vet、PowerShell 可重现发行包与插件打包检查。当前实现提交未推送且无 PR,因此状态为 **PENDING**。 + +补充交叉编译:使用 `CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c` 分别编译 `memory`、`reviewv4`、`sessionindex`、`inspect`、`annotation`、`pricing`、`migrationv4`、`publication`、`syncproject`和 `cli`,10/10 通过。该结果只证明 Windows amd64 可编译,不是 Windows 原生执行证据。 + +## 明确不属于 Gate 0 的后续工作 + +- Session index 累积发布与受限查询; +- Obsidian “全部 Sessions”浏览器、虚拟列表与真实 Vault 验收; +- 人工决策/约定、AI 候选提炼、CAS 确认与发布; +- ModelPriceWatch 缓存、匹配、不可变价格快照、补价和用量卡片。 + +上述四组仍需按各自实施计划完成,不因本地 Gate 0 验收而视为已实现。 diff --git a/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md index e8ee5a1..533c141 100644 --- a/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md +++ b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md @@ -1,6 +1,6 @@ # SessionReviewer Obsidian 项目脉络、决策与价格查询设计 -- 状态:用户复核通过,进入实施计划阶段 +- 状态:Gate 0 本地验收通过;Windows CI 待运行 - 日期:2026-09-04 - 适用范围:SessionReviewer 零 Token 扫描、macOS/Windows Obsidian Desktop 项目脉络浏览器、Project/Vault 投影 - 扩展:`2026-08-25-session-reviewer-project-evolution-browser-design.md` From 6421a9aad6d7a65bbaef2fa71e4d7e7be3431db6 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 00:14:15 +0800 Subject: [PATCH 15/25] docs: define problem context and evolution closure --- docs/session-review/gate-0-evidence.md | 4 +- ...04-conversation-chain-evolution-closure.md | 333 +++++++++++++++++ .../2026-09-04-decisions-and-candidates.md | 17 +- .../2026-09-04-modelpricewatch-pricing.md | 2 +- .../2026-09-04-obsidian-all-sessions-view.md | 47 +-- ...09-04-obsidian-context-gate-0-contracts.md | 113 +++++- .../plans/2026-09-04-problem-map-placement.md | 278 +++++++++++++++ ...idian-project-context-navigation-design.md | 335 +++++++++++++++--- 8 files changed, 1037 insertions(+), 92 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-04-conversation-chain-evolution-closure.md create mode 100644 docs/superpowers/plans/2026-09-04-problem-map-placement.md diff --git a/docs/session-review/gate-0-evidence.md b/docs/session-review/gate-0-evidence.md index 4255bb9..20454b0 100644 --- a/docs/session-review/gate-0-evidence.md +++ b/docs/session-review/gate-0-evidence.md @@ -2,9 +2,9 @@ ## 结论 -**LOCAL COMPLETE / WINDOWS CI PENDING** +**SUPERSEDED BY CONTRACT EXTENSION / GATE 0 REOPENED** -Gate 0 在当前 macOS 工作树的实现与合同验收均通过。由于审计提交尚未推送且没有 Pull Request,`.github/workflows/ci.yml` 中 `windows-x64` / `windows-latest` 原生执行证据尚不存在。因此本记录不宣称 Gate 0 整体完成,也不以 Windows 交叉编译代替原生 CI。 +以下记录仍证明原八组合同在当前 macOS 工作树通过,但 2026-09-04 后续确认的 `conversation-chain-v1`、`problem-map-candidate-v1`、正式问题图和演进闭环扩展尚未包含在该矩阵中。Gate 0 因此重新打开;必须完成 `2026-09-04-obsidian-context-gate-0-contracts.md` Task 7 并重跑完整门禁。由于审计提交尚未推送且没有 Pull Request,`.github/workflows/ci.yml` 中 `windows-x64` / `windows-latest` 原生执行证据也仍不存在。 ## 审计对象 diff --git a/docs/superpowers/plans/2026-09-04-conversation-chain-evolution-closure.md b/docs/superpowers/plans/2026-09-04-conversation-chain-evolution-closure.md new file mode 100644 index 0000000..19dd09b --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-conversation-chain-evolution-closure.md @@ -0,0 +1,333 @@ +# Conversation Chain and Evolution Closure Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a zero-token, provider-neutral Q/A execution chain and use it to replace placeholder Project Evolution details with traceable milestone closure summaries. + +**Architecture:** Materialize a private `conversation-chain-v1` beside each accepted SessionView using visible user/assistant messages and bounded tool evidence only. Project projection promotes only qualified milestones and stores human-editable closure fields plus source turn references in `review-presentation-v4`; Obsidian reads concise closures by default and queries the private chain only when the user expands evidence. + +**Tech Stack:** Go 1.26, existing Observation/SessionView/ProjectView and publication layers, canonical JSON, TypeScript 5.8, Obsidian 1.13, Vitest/jsdom. + +**Spec:** `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` + +## Global Constraints + +- Prerequisites: the reopened Gate 0 Task 7 is complete for `conversation-chain-v1`, expanded `review-presentation-v4`, CLI grammar, Go/TypeScript parity and native Windows CI; the Codex, Claude Code and OpenCode SourceAdapters from `2026-08-30-multi-agent-session-review.md` are present and passing. +- Ordinary scans and deterministic projection start zero Agent processes. No model summarizes a reply unless the user explicitly requests an AI candidate. +- Only visible `user` and `assistant` content participates. System/developer instructions, hidden reasoning, encrypted compaction and opaque content are excluded. +- A turn unit starts at one visible user message and ends immediately before the next visible user message in the same provider Session. +- The private chain retains bounded visible Q/A excerpts and authenticated source refs; Vault Markdown contains only bounded closure text and source references. 回答正文 is read on demand through `SourceAdapter.Read`, never persisted as a transcript or raw tool output. +- `execution_verified` is a machine evidence state. It never implies the human workflow state `resolved`. +- Cross-Session chains require explicit stable evidence. Semantic similarity alone produces a candidate in the problem-map plan and never mutates a milestone. +- Existing human edits win over generated fields. Migration is explicit, digest-bound, previewable and idempotent. + +## File Structure and Ownership + +- `internal/conversationchain/materialize.go`: deterministic turn segmentation and action/result attachment. +- `internal/conversationchain/store.go`: private generation-bound chain persistence and retention. +- `internal/inspect/conversation.go`: bounded read-only chain query and cursor binding. +- `internal/presentation/milestone.go`: qualification rules and generated closure baselines. +- `internal/migrationv4/`: classification preview for old automatic `user_request` events. +- `obsidian-plugin/src/cli/runner.ts`: fixed-argv conversation-chain query. +- `obsidian-plugin/src/view/render-evolution.ts`: milestone rail and closure detail. +- `obsidian-plugin/src/view/render-closure.ts`: closure sections, coverage, source links and expandable answer. + +--- + +### Task 1: Materialize deterministic conversation turn units + +**Files:** +- Create: `internal/conversationchain/materialize.go`, `materialize_test.go` +- Modify: `internal/source/codex/decode.go`, `decode_test.go` +- Modify: `internal/source/claude/adapter.go` and its tests +- Modify: `internal/source/opencode/export.go`, `adapter.go` and their tests +- Modify: `internal/memory/types.go`, `types_test.go` +- Modify: `internal/sessionview/materialize.go`, `materialize_test.go` +- Modify: `internal/scan/service.go`, `service_test.go` + +**Interfaces:** + +```go +type MaterializeInput struct { + ProjectID string + Provider string + SessionID string + SessionViewDigest string + Observations []memory.ObservationRevision + RuleVersion string +} +func Materialize(MaterializeInput) (conversationchain.Document, error) +``` + +- [ ] **Step 1: Write failing boundary tests.** Cover one user/one assistant, multiple assistant messages, tools between answer fragments, a second user message, a user message without an answer, an interrupted final assistant message, a stable-prefix-only Session, malformed timestamps and duplicate revision IDs. Assert the deterministic conclusion excerpt is the last non-empty complete visible assistant message and incomplete tails remain visibly partial. + +```go +func TestMaterializeEndsTurnBeforeNextVisibleUserMessage(t *testing.T) { + got := materialize(t, user("u1", "first"), assistant("a1", "answer"), toolResult("r1", "PASS"), user("u2", "second")) + if len(got.TurnUnits) != 2 { t.Fatalf("turns=%d", len(got.TurnUnits)) } + if got.TurnUnits[0].UserMessage.RevisionID != "u1" || len(got.TurnUnits[0].AssistantMessages) != 1 { t.Fatalf("bad first turn: %+v", got.TurnUnits[0]) } + if got.TurnUnits[1].AnswerState != conversationchain.AnswerNone { t.Fatalf("invented answer: %+v", got.TurnUnits[1]) } +} +``` + +- [ ] **Step 2: Write failing decoder privacy and size tests.** Emit bounded `visible_user_message` and `visible_assistant_message` observations with authenticated source refs and at most 4,096 UTF-8 bytes of excerpt. Feed system/developer/analysis records and an oversized visible message; assert forbidden roles never emit an observation and truncation metadata is exact. Keep full text out of SessionView summaries. + +- [ ] **Step 3: Run RED.** + +Run: `go test ./internal/conversationchain ./internal/sessionview -run 'Materialize|Turn|Visible|Chunk' -count=1` + +Expected: FAIL because `Materialize` and the visibility adapter do not exist. + +- [ ] **Step 4: Extend all three source decoders and implement a single-pass state machine.** Codex response messages, Claude assistant content blocks and finalized OpenCode assistant text emit the same bounded `visible_assistant_message` observation; each provider's visible user text emits `visible_user_message`. Open a unit only on a user observation, append assistant observations and bounded action/result references until the next user observation, close the final unit at end-of-source, and derive stable IDs from provider/session/user revision/rule version. The scan service materializes the chain from the same authenticated observation set as SessionView, then stores only the chain digest in the prepared generation manifest. + +```go +func turnUnitID(provider, sessionID, userRevision, ruleVersion string) string { + return "turn-" + digestID(provider+"\x00"+sessionID+"\x00"+userRevision+"\x00"+ruleVersion) +} +``` + +- [ ] **Step 5: Run GREEN and the zero-token boundary.** + +Run: `gofmt -w internal/conversationchain internal/source internal/memory internal/sessionview internal/scan && go test ./internal/conversationchain ./internal/source/... ./internal/memory ./internal/sessionview ./internal/scan ./test/zerotoken -count=1` + +- [ ] **Step 6: Commit when authorized.** + +```bash +git add internal/conversationchain internal/source internal/memory internal/sessionview internal/scan test/zerotoken +git commit -m "feat: materialize deterministic conversation chains" +``` + +--- + +### Task 2: Persist and query private chains without copying transcripts to Vault + +**Files:** +- Create: `internal/conversationchain/store.go`, `store_test.go` +- Create: `internal/inspect/conversation.go`, `conversation_test.go` +- Modify: `internal/source/adapter.go`, `internal/source/codex/adapter.go` +- Modify: `internal/memorystore/store.go`, `store_test.go`, `retention.go`, `retention_test.go` +- Modify: `internal/cli/run.go`, `run_test.go` + +**Interfaces:** + +```go +type ChainStore interface { + Put(context.Context, conversationchain.Document) error + Get(context.Context, string, string, string) (conversationchain.Document, error) +} +func (s *Service) ConversationChain(ctx context.Context, request ConversationChainRequest) (conversationchain.Page, error) +``` + +- [ ] **Step 1: Write failing store tests.** Assert atomic replacement, exact generation/session binding, dependency digest mismatch rejection, source-unavailable reads from the last accepted chain, and retention of the revision referenced by the active presentation. + +- [ ] **Step 2: Write failing paging and authenticated-read tests.** Query the unit index, a selected turn, the first/middle/last visible message, wrong provider/session/generation, stale message cursor, oversized limit, changed source hash, a source record over 64 KiB and a missing chain. + +```go +func TestConversationCursorCannotCrossTurnUnits(t *testing.T) { + cursor := pageCursor(t, request("turn-a")) + _, err := service.ConversationChain(context.Background(), withCursor(request("turn-b"), cursor)) + assertCode(t, err, cli.ContractCodeStaleCursor) +} +``` + +- [ ] **Step 3: Run RED.** + +Run: `go test ./internal/conversationchain ./internal/inspect ./internal/memorystore ./internal/cli -run 'Conversation|Chain|Retention' -count=1` + +- [ ] **Step 4: Implement generation-bound storage and HMAC cursor binding.** Store bounded excerpts and source refs under the existing private project/session namespace. Cursors bind project, provider, session, generation, turn unit, sanitization version, message ordinal and limit. For an expanded turn, call `SourceAdapter.Read` with the stored ref and a 64 KiB maximum, verify the source hash, decode only the referenced visible user/assistant record, redact again and never persist the response body. + +- [ ] **Step 5: Wire the fixed CLI command.** Route `inspect conversation-chain` to the service, enforce the Gate-0 response byte cap and timeout, and return stable `generation_mismatch`, `stale_cursor`, `response_too_large` and `source_unavailable` codes. + +- [ ] **Step 6: Run full Go gates and commit when authorized.** + +Run: `gofmt -w internal/conversationchain internal/inspect internal/memorystore internal/cli && go test ./... && go vet ./... && go mod tidy -diff` + +```bash +git add internal/conversationchain internal/inspect internal/source internal/memorystore internal/cli +git commit -m "feat: query private conversation chains" +``` + +--- + +### Task 3: Project only qualified milestones and build closure baselines + +**Files:** +- Create: `internal/presentation/milestone.go`, `milestone_test.go` +- Modify: `internal/presentation/project.go`, `project_test.go`, `render.go`, `render_test.go` +- Modify: `internal/reviewv4/types.go`, `validate.go` + +**Interfaces:** + +```go +func QualifyMilestones(project memory.ProjectView, chains []conversationchain.Document, accepted reviewv4.Presentation) []reviewv4.Timeline +func GeneratedClosure(event MilestoneEvidence) reviewv4.ClosedLoop +``` + +- [ ] **Step 1: Write failing qualification tests.** A plain user request is not a milestone. Explicit confirmation, completed implementation, supported verification, release, rollback, major failure and direction adjustment qualify. Repeated tool noise and assistant prose without evidence do not. + +- [ ] **Step 2: Write failing closure tests.** Assert exact section order, bounded visible answer excerpt, source turn refs for every segment, `missing` rather than filler text, and human patch precedence over the generated baseline. + +```go +func TestGeneratedClosureDoesNotInventMissingVerification(t *testing.T) { + got := GeneratedClosure(answerOnlyEvidence()) + if got.Verification.State != "missing" || got.Verification.Text != "" { t.Fatalf("invented verification: %+v", got.Verification) } + if got.Conclusion.Kind != "visible_answer_excerpt" { t.Fatalf("wrong conclusion kind: %s", got.Conclusion.Kind) } +} +``` + +- [ ] **Step 3: Run RED.** + +Run: `go test ./internal/presentation ./internal/reviewv4 -run 'Milestone|Closure|UserRequest' -count=1` + +- [ ] **Step 4: Replace the 20-request projector.** Remove automatic `user_request` promotion in `projectHistoryEvents`; select only typed evidence or accepted human events. Generate closure fields without changing machine timestamps, command exit codes or source identities. + +- [ ] **Step 5: Render the closure into the existing two Markdown documents.** Keep full chain text private; write concise fields and stable source references inside the existing event block so no third visible document is created. + +- [ ] **Step 6: Run focused and full gates, then commit when authorized.** + +Run: `gofmt -w internal/presentation internal/reviewv4 && go test ./internal/presentation ./internal/reviewv4 -count=1 && go test ./... && go vet ./... && go mod tidy -diff` + +```bash +git add internal/presentation internal/reviewv4 +git commit -m "feat: project milestone closure summaries" +``` + +--- + +### Task 4: Add optional dependency-cached Agent conclusion candidates + +**Files:** +- Modify: `internal/annotation/types.go` +- Create: `internal/annotation/store.go`, `store_test.go`, `paths.go`, `paths_test.go` +- Create: `internal/cli/evolution.go`, `evolution_test.go` +- Modify: `internal/agent/agent.go`, `agent_test.go` +- Modify: `internal/reviewjob/agent_handle.go`, `service_test.go` +- Modify: `obsidian-plugin/src/cli/runner.ts`, `obsidian-plugin/tests/cli.test.ts` + +**Interfaces:** + +```go +func (s *Service) RequestConclusionCandidate(context.Context, ConclusionCandidateRequest) (annotation.Annotation, error) +func (s *Service) TransitionConclusionCandidate(context.Context, ConclusionTransitionRequest) (reviewv4.Presentation, error) +``` + +- [ ] **Step 1: Write failing generic annotation-store tests.** Cover private path confinement, atomic replace, revision CAS, `annotation_kind`, terminal state mutation rejection and `confirmed_entity_id` validation. + +- [ ] **Step 2: Write failing dependency-cache tests.** Repeating `evolution summarize` with the same milestone, sorted source turn digests, extractor version and prompt schema returns the same run with one Agent start. Changed dependencies create one new run; invalid or failed output does not advance the successful dependency set. + +```go +func TestConclusionSummaryReusesIdenticalDependencies(t *testing.T) { + first := summarize(t, service, request("milestone-1", "d2", "d1")) + second := summarize(t, service, request("milestone-1", "d1", "d2")) + if first.ID != second.ID || agent.Starts() != 1 { t.Fatalf("summary cache miss: %d", agent.Starts()) } +} +``` + +- [ ] **Step 3: Run RED.** + +Run: `go test ./internal/annotation ./internal/cli ./internal/agent ./internal/reviewjob -run 'Conclusion|Annotation|Dependency' -count=1 && (cd obsidian-plugin && npm test -- cli.test.ts)` + +- [ ] **Step 4: Implement the explicit request path through the verified provider-neutral Agent handle.** Send only the selected milestone's bounded visible Q/A excerpts and evidence refs to the invoking Agent or configured Obsidian proposal worker. Require strict `milestone_conclusion_candidate` JSON and reject unknown milestones, invented source refs and prose outside the schema. OpenCode remains proposal-only through its documented supported path; no UI label may imply unsupported execution. + +- [ ] **Step 5: Implement confirmation as a narrow HumanPresentation patch.** Confirmation changes only `closed_loop.conclusion.text` and `conclusion_kind=ai_candidate_confirmed`; ignore/restore affect only the private candidate. Review SHA and candidate revision mismatch write nothing. + +- [ ] **Step 6: Run full gates and commit when authorized.** + +```bash +git add internal/annotation internal/cli internal/agent internal/reviewjob obsidian-plugin/src/cli obsidian-plugin/tests/cli.test.ts +git commit -m "feat: add optional milestone conclusion candidates" +``` + +--- + +### Task 5: Migrate old placeholder events explicitly and idempotently + +**Files:** +- Modify: `internal/migrationv4/types.go`, `plan.go`, `migrate.go` +- Modify: `internal/migrationv4/migrate_test.go` +- Modify: `internal/problemmap/candidate_codec.go`, `validate.go` +- Create: `testdata/contracts/migration/v3-placeholder-events/` +- Modify: `internal/cli/sync_test.go` + +**Interfaces:** + +```go +type EventClassification struct { + EventID string `json:"event_id"` + Action string `json:"action"` // upgrade_milestone|move_problem|preserve_human|unclassified + TargetID string `json:"target_id,omitempty"` + ReasonCodes []string `json:"reason_codes"` +} +``` + +- [ ] **Step 1: Write failing migration tests.** Include an untouched generated placeholder, a human-patched event, a request followed by verified implementation, an unanswered request and a missing source. Assert all four action counts and stable IDs in dry-run. + +- [ ] **Step 2: Run RED.** + +Run: `go test ./internal/migrationv4 ./internal/cli -run 'Placeholder|EventClassification|MigrationPreview' -count=1` + +- [ ] **Step 3: Implement classification from authenticated dependencies.** Treat exact v3 generated baseline plus no active human patch as automatic. Preserve patched/unknown blocks. Convert plain questions without a confirmed parent into deterministic `problem-map-candidate-v1` records with `recommended_relation=keep_pending`; only an explicit legacy hierarchy may create a formal node. Never delete source refs or treat “已纳入项目脉络索引” as verification. + +- [ ] **Step 4: Bind classification to the migration preview digest.** Any chain dependency, human patch, source preimage or classification result change returns `migration_preview_stale` at confirmation. + +- [ ] **Step 5: Prove idempotence and byte stability.** Run preview twice, migrate once, then render/sync/reload twice. Require identical v4 bytes and no new revision on the second pass. + +- [ ] **Step 6: Run full gates and commit when authorized.** + +```bash +git add internal/migrationv4 internal/problemmap internal/cli testdata/contracts/migration +git commit -m "feat: migrate placeholder evolution nodes" +``` + +--- + +### Task 6: Render and verify the Obsidian closure detail + +**Files:** +- Create: `obsidian-plugin/src/view/render-closure.ts` +- Modify: `obsidian-plugin/src/view/render-evolution.ts`, `render-shell.ts`, `styles.css` +- Modify: `obsidian-plugin/src/cli/runner.ts` +- Create: `obsidian-plugin/tests/evolution-closure.test.ts` +- Modify: `obsidian-plugin/tests/accessibility.test.ts`, `large-history.test.ts`, `cli.test.ts` + +**Interfaces:** + +```ts +export function renderClosure(event: TimelineV4, model: BrowserModelV4, state: ViewState, actions: ClosureActions): HTMLElement; +export interface ClosureActions { + loadTurn(key: SessionIdentity, turnUnitId: string, messageCursor?: string): Promise; + openSource(key: SessionIdentity, turnUnitId: string): Promise; + openProblem(problemId: string): void; +} +``` + +- [ ] **Step 1: Write failing DOM tests.** Assert the five fixed sections, real source badges, missing/partial coverage copy, compact default answer, expand/collapse, stale-generation recovery and absence of the old generic labels. + +```ts +it("shows a missing answer honestly", () => { + const detail = renderClosure(milestone({ conclusionKind: "missing" }), model(), state(), actions()); + expect(detail.textContent).toContain("未捕获 Agent 回答"); + expect(detail.textContent).not.toContain("已纳入项目脉络索引"); +}); +``` + +- [ ] **Step 2: Write failing keyboard and CLI tests.** Tab reaches “查看回答正文”, “打开原 Session” and “查看关联问题”; Enter/Space activate them; fixed argv contains provider/session/turn separately and `shell:false`. + +- [ ] **Step 3: Run RED.** + +Run: `cd obsidian-plugin && npx vitest run tests/evolution-closure.test.ts tests/accessibility.test.ts tests/cli.test.ts` + +- [ ] **Step 4: Implement the closure component.** Use semantic headings and lists, preserve Obsidian theme variables, keep on-demand answer正文 collapsed, label 64 KiB truncation and source-unavailable states, announce loaded/error states through one polite live region, and never inject source text through `innerHTML`. + +- [ ] **Step 5: Run plugin and repository gates.** + +Run: `cd obsidian-plugin && npm run check`; then from repository root run `go test ./... && go vet ./... && go mod tidy -diff && git diff --check`. + +- [ ] **Step 6: Install the built bundle into a disposable real Vault.** Open the first AgentWiki milestone and verify: answer content is visible; each segment names its true Session; missing evidence is explicit; answer正文 reading is bounded and non-persistent; keyboard navigation works; restart and reopen preserve selection without changing files. + +- [ ] **Step 7: Record evidence and commit when authorized.** Save bundle hash, Obsidian version, fixture/Vault path, screenshots and observed coverage in `docs/session-review/evolution-closure-acceptance.md`. + +```bash +git add obsidian-plugin docs/session-review/evolution-closure-acceptance.md +git commit -m "feat: show closed-loop evolution details" +``` diff --git a/docs/superpowers/plans/2026-09-04-decisions-and-candidates.md b/docs/superpowers/plans/2026-09-04-decisions-and-candidates.md index fc6de89..1a1cb72 100644 --- a/docs/superpowers/plans/2026-09-04-decisions-and-candidates.md +++ b/docs/superpowers/plans/2026-09-04-decisions-and-candidates.md @@ -12,7 +12,7 @@ ## Global Constraints -- Prerequisites: Gate 0, Session index/query, and Obsidian four-tab shell are complete. +- Prerequisites: reopened Gate 0, Session index/query, and the Obsidian five-tab shell are complete. - Formal decisions originate only from `human_created`, `migrated`, or `ai_candidate_confirmed` provenance. - Extraction failure, cancellation, invalid output, or candidate CAS conflict never changes the scan generation or extraction watermark. - Candidates cite `(provider, session_id, session_view_digest, revision_id)` dependencies and contain no unverifiable confidence score. @@ -20,6 +20,7 @@ - Decision supersession is acyclic. No physical delete is exposed; archive/supersede creates a new revision. - Write commands read at most 64 KiB versioned JSON from stdin, validate review SHA and expected revision, and accept no caller-supplied file path. - Human publication verifies `session-index.json` generation/digest but keeps its bytes unchanged. +- Decision commands and UI filter `annotation_kind` to `decision_candidate|agreement_candidate`; they never list, confirm, ignore or stale a `milestone_conclusion_candidate` through the decision workflow. ## File Structure and Ownership @@ -82,11 +83,11 @@ func ValidateDecisionSet(values []Decision) error { --- -### Task 2: Implement private AgentAnnotation CAS storage +### Task 2: Extend private AgentAnnotation CAS storage for decisions **Files:** -- Create: `internal/annotation/store.go`, `store_test.go` -- Create: `internal/annotation/paths.go`, `paths_test.go` +- Modify: `internal/annotation/store.go`, `store_test.go` +- Modify: `internal/annotation/paths.go`, `paths_test.go` - Modify: `internal/atomicfile/` only if a missing reusable lock primitive is proven **Interfaces:** @@ -98,13 +99,13 @@ type Store interface { } type ProjectState struct { SchemaVersion, Revision int - Candidates []CandidateRevision + Annotations []AnnotationRevision Runs []ExtractionRun - LastSuccessfulExtractionDependencies []string + LastSuccessfulDependencies map[AnnotationKind][]string } ``` -- [ ] **Step 1: Write RED tests** for private permissions, project ID path confinement, atomic replace, concurrent revision conflict, duplicate candidate revision, terminal-state mutation rejection, stale marking, and crash recovery. +- [ ] **Step 1: Write RED tests** for private permissions, project ID path confinement, atomic replace, concurrent revision conflict, duplicate annotation revision, kind-specific filtering, terminal-state mutation rejection, stale marking, and crash recovery. Include an existing milestone conclusion candidate and prove decision operations leave it byte-identical. ```go func TestStoreCompareAndSwapRejectsStaleRevision(t *testing.T) { @@ -261,7 +262,7 @@ it("explains an empty decision set and exposes two explicit actions", () => { ``` - [ ] **Step 2: Add RED candidate interactions** for start/status/cancel, pending cards with evidence, edit-confirm, ignore, not-decision, restore, stale disabled confirmation, CAS refresh, and zero confidence display. - [ ] **Step 3: Run RED:** `cd obsidian-plugin && npm test -- decisions-v4-view.test.ts cli.test.ts`. -- [ ] **Step 4: Implement full CLI methods and UI states** using fixed argv and bounded stdin. On a write success reload the four-file repository; on typed conflict show current summary and preserve unsaved form text. +- [ ] **Step 4: Implement full CLI methods and UI states** using fixed argv and bounded stdin. On a write success reload the four-file repository; on typed conflict show current summary and preserve unsaved form text. Keep the five-tab order and problem view state unchanged. ```ts async function confirmCandidate(candidate: CandidateRevision, input: DecisionInput): Promise { diff --git a/docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md b/docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md index 823deb2..58622ce 100644 --- a/docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md +++ b/docs/superpowers/plans/2026-09-04-modelpricewatch-pricing.md @@ -12,7 +12,7 @@ ## Global Constraints -- Prerequisites: Gate 0 and Session index publication are complete. Obsidian usage work can follow after the four-tab shell exists. +- Prerequisites: reopened Gate 0 and Session index publication are complete. Obsidian usage work follows after the five-tab shell exists. - Verified live API shapes on 2026-09-04: `models.json` is `{count, updated, data: Listing[]}` and includes `id`, `provider`, `model`, nullable price fields, `promo`, `promo_until`, `price_note`, `pricing_url`, `last_updated`, and `detail_url`; `price-history.json` is `{count, updated, data: {listingID: {model, provider, history[]}}}`. Treat this as an adapter version, not a perpetual guarantee. - Endpoints are fixed HTTPS URLs: `https://modelpricewatch.com/api/v1/models.json` and `https://modelpricewatch.com/api/v1/price-history.json`. Redirects may remain only on the same origin and must end at HTTPS. - Each response has a 128 MiB download and parse ceiling, must have successful status, JSON content type, supported schema, unique fields, and complete EOF. diff --git a/docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md b/docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md index c7a6695..466144e 100644 --- a/docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md +++ b/docs/superpowers/plans/2026-09-04-obsidian-all-sessions-view.md @@ -12,8 +12,8 @@ ## Global Constraints -- Prerequisites: Gate 0 and `2026-09-04-session-index-publication-query.md` are complete. -- Tab order and labels are exactly `项目演进`, `决策与约定`, `全部 Sessions`, `用量`. +- Prerequisites: reopened Gate 0, `2026-09-04-conversation-chain-evolution-closure.md`, `2026-09-04-problem-map-placement.md`, and `2026-09-04-session-index-publication-query.md` are complete. +- Tab order and labels are exactly `项目演进`, `问题脉络`, `决策与约定`, `全部 Sessions`, `用量`. - The index list is complete. Virtualization changes DOM node count only; it never slices the data model or hides total/current-range counts. - Without a verified CLI, date/provider/processing/source-availability filters and the full index still work. Only summary, deep events, and branch/file/error searches are disabled, with one recovery action. - CLI calls use `execFile` with `shell:false`, absolute executable, fixed arrays, 10-second timeout, and bounded stdout/stderr. No user string becomes a path or executable. @@ -26,7 +26,7 @@ - `obsidian-plugin/src/data/repository.ts`: four-file snapshot loading, hash/generation validation, watchers. - `obsidian-plugin/src/cli/runner.ts`: fixed inspect methods and strict response parsing. - `obsidian-plugin/src/state/store.ts`: persisted filter/selection/page state, no event payload cache. -- `obsidian-plugin/src/view/render-shell.ts`: four-tab navigation. +- `obsidian-plugin/src/view/render-shell.ts`: five-tab navigation. - `obsidian-plugin/src/view/render-sessions.ts`: coverage, filters, list, detail, paging and recovery states. - `obsidian-plugin/src/view/virtual-list.ts`: bounded DOM window over a complete array. @@ -134,7 +134,7 @@ export interface SessionFilter { startedTo?: string; } export interface ViewState { - view: "evolution"|"decisions"|"sessions"|"usage"; + view: "evolution"|"problems"|"decisions"|"sessions"|"usage"; selectedSession?: SessionIdentity; sessionFilter: SessionFilter; sessionOrdinal: number; @@ -159,30 +159,31 @@ export function sameSession(left?: SessionIdentity, right?: SessionIdentity): bo return left !== undefined && right !== undefined && left.provider === right.provider && left.sessionId === right.sessionId; } ``` -- [ ] **Step 4: Change shell tabs to the exact four-item order** with ArrowLeft/Right/Home/End keyboard behavior and `sessions` panel dispatch. +- [ ] **Step 4: Preserve the exact five-item shell order** with ArrowLeft/Right/Home/End keyboard behavior and add `sessions` panel dispatch without changing the existing problem view state. - [ ] **Step 5: Run `npm run check` and commit when authorized** with message `feat: model complete session navigation state`. --- -### Task 4: Preserve readable Project Evolution progressive disclosure +### Task 4: Preserve the existing Problem and Evolution views while adding Sessions **Files:** - Modify: `obsidian-plugin/src/view/render-evolution.ts` +- Modify: `obsidian-plugin/src/view/render-problems.ts` - Modify: `obsidian-plugin/src/view/render-shell.ts` - Modify: `obsidian-plugin/tests/large-history.test.ts`, `view.test.ts` -- Modify: `internal/presentation/project.go`, `project_test.go` -- [ ] **Step 1: Write RED projection tests** proving deterministic machine evidence creates only neutral milestones (verification, commit, release, rollback, major error) and never invents reason, meaning, direction, or next action. +- [ ] **Step 1: Write RED regression tests** proving Session state changes do not reset the selected problem path, collapse the problem tree, change an expanded evolution closure, or alter the five-tab keyboard order. -```go -func TestProjectDoesNotPromoteAtomicFactsOrInventMeaning(t *testing.T) { - output := projectFromFacts(t, 40, withNoHumanSemantics()) - if len(output.Events) >= 40 { t.Fatalf("atomic facts leaked as milestones: %d", len(output.Events)) } - for _, event := range output.Events { if event.Why != "" || event.Next != "" { t.Fatalf("invented semantics: %+v", event) } } -} +```ts +it("keeps problem and evolution state when opening Sessions", () => { + const before = state({ selectedProblemId: "p-2", selectedEventId: "m-3" }); + const after = reduceView(before, { type: "select-view", view: "sessions" }); + expect(after.selectedProblemId).toBe("p-2"); + expect(after.selectedEventId).toBe("m-3"); +}); ``` -- [ ] **Step 2: Write RED UI tests** for recent mode showing milestone total plus omitted count, “查看全部”, complete search/virtual list mode, and distinct `机器验证`/`人工确认` source labels. +- [ ] **Step 2: Write RED UI tests** for recent milestone total, “查看全部”, closure answer expansion, problem tree depth and source links before and after a Sessions repository refresh. ```ts it("shows the milestone total when recent mode is compact", () => { @@ -192,16 +193,20 @@ it("shows the milestone total when recent mode is compact", () => { }); ``` -- [ ] **Step 3: Run RED:** `go test ./internal/presentation -run Milestone -count=1 && (cd obsidian-plugin && npm test -- large-history.test.ts view.test.ts)`. -- [ ] **Step 4: Implement typed milestone selection and explicit totals.** Remove atomic event-ID lists from human Markdown and browser cards; keep evidence identity behind the Session query surface. +- [ ] **Step 3: Run RED:** `cd obsidian-plugin && npm test -- large-history.test.ts view.test.ts`; expect state loss or missing Sessions dispatch before the integration change. +- [ ] **Step 4: Add Sessions state without replacing existing view subtrees.** Keep selection keys by stable IDs, dispatch each of the five panels independently and update only the active panel on Session paging. ```ts -const visibleMilestones = state.fullHistory ? filteredMilestones : filteredMilestones.slice(0, RECENT_MILESTONE_LIMIT); -heading.append(element("span", { text: `共 ${filteredMilestones.length} 个里程碑` })); -if (!state.fullHistory && visibleMilestones.length < filteredMilestones.length) heading.append(showAllButton(update)); +const renderers: Record HTMLElement> = { + evolution: () => renderEvolution(model, state, update, actions), + problems: () => renderProblems(model, state, actions), + decisions: () => renderDecisions(model, state, actions), + sessions: () => renderSessions(model, state, actions), + usage: () => renderUsage(model, state) +}; ``` -- [ ] **Step 5: Run Go/plugin full gates and commit when authorized** with message `feat: keep project evolution complete and readable`. +- [ ] **Step 5: Run `npm run check` and commit when authorized** with message `feat: integrate complete session navigation`. --- diff --git a/docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md b/docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md index 58fd6f5..67e60b1 100644 --- a/docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md +++ b/docs/superpowers/plans/2026-09-04-obsidian-context-gate-0-contracts.md @@ -2,9 +2,9 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Freeze and validate every v4 persistence, state-machine, CLI, migration, and provider-neutral contract before any of the four user-facing features are implemented. +**Goal:** Freeze and validate every v4 persistence, state-machine, CLI, migration, and provider-neutral contract before any of the six user-facing features are implemented. -**Architecture:** Keep the existing immutable Observation/SessionView/ProjectView store as the factual layer. Add strict v4 wire contracts and validators at package boundaries, with TypeScript mirrors for Obsidian. Gate 0 defines data shapes and command grammars only; feature services are implemented by the four follow-on plans. +**Architecture:** Keep the existing immutable Observation/SessionView/ProjectView store as the factual layer. Add strict v4 wire contracts and validators at package boundaries, with TypeScript mirrors for Obsidian. Conversation chains remain private deterministic derivatives; the formal problem map and milestone closures live in HumanPresentation, while placement candidates remain private. Gate 0 defines data shapes and command grammars only; feature services are implemented by the six follow-on plans. **Tech Stack:** Go 1.26, JSON Schema, existing canonical JSON/digest helpers, TypeScript 5.8, Vitest, Obsidian 1.13. @@ -13,7 +13,7 @@ ## Global Constraints - Start from the released `0.3.5` v3 architecture at commit `ea5b1ba` in isolated branch `codex/obsidian-context-v4`. The original dirty worktree remains untouched; do not reset, checkout, clean, or overwrite it. -- This plan is the prerequisite for the four feature plans dated 2026-09-04. Do not implement UI behavior here. +- This plan is the prerequisite for the six feature plans dated 2026-09-04. Do not implement UI behavior here. - Generic schemas use `(provider, session_id)` identity and never constrain provider to `codex`. - Unknown prices are `null`, never numeric zero. Human-confirmed semantics and deterministic machine facts remain separate. - All decoders reject duplicate JSON keys, unknown fields, oversized input, invalid UTF-8, non-canonical enums, inconsistent counts, and trailing JSON. @@ -23,12 +23,14 @@ ## File Structure and Ownership -- `schemas/`: normative JSON Schema documents for seven persisted/read contracts plus the `pricing-supplement-v1` input contract. +- `schemas/`: normative JSON Schema documents for nine persisted/read contracts plus the `pricing-supplement-v1` input contract. - `internal/reviewv4/`: review-presentation-v4 and machine-ledger-v4 types, codecs, cross-record invariants. - `internal/sessionindex/`: session-index-v1 types and validation. - `internal/inspect/`: session-summary-v1 and session-event-page-v1 response types. - `internal/annotation/`: agent-annotation-v1 candidate and extraction-run types. - `internal/pricing/`: pricing-snapshot-v1 types and validation only. +- `internal/conversationchain/`: conversation-chain-v1 types, canonical codec and invariants only. +- `internal/problemmap/`: problem-map-candidate-v1 types, graph validation and candidate transition validation only. - `internal/cli/contracts.go`: command grammar, bounded scalar validators, and stable error codes; no service implementation. - `obsidian-plugin/src/contracts/review-v4.ts`: TypeScript mirrors used by later repository and view work. - `testdata/contracts/v4/` and `obsidian-plugin/tests/fixtures/v4/`: shared valid/invalid compatibility fixtures. @@ -39,13 +41,15 @@ |---|---| | Principles, version boundary, persisted contracts, state enums, compatibility matrix | This Gate 0 plan | | Complete cumulative Sessions, provider fan-in, four-file publication, summaries/events/search | `2026-09-04-session-index-publication-query.md` | -| Four-tab order, readable evolution, complete virtual list, CLI degradation, installed Obsidian behavior | `2026-09-04-obsidian-all-sessions-view.md` | +| Five-tab shell, readable evolution, complete virtual list, CLI degradation, installed Obsidian behavior | `2026-09-04-obsidian-all-sessions-view.md` | | Human decisions/agreements, candidate extraction/CAS, three-file human publication | `2026-09-04-decisions-and-candidates.md` | | ModelPriceWatch cache/matching, billable quantities, immutable snapshots, supplements, usage cards | `2026-09-04-modelpricewatch-pricing.md` | +| Visible Q/A segmentation, cross-Session evidence chains, milestone closure and migration | `2026-09-04-conversation-chain-evolution-closure.md` | +| Formal problem tree, deterministic placement candidates and Obsidian interaction | `2026-09-04-problem-map-placement.md` | --- -### Task 1: Freeze the seven schemas and shared enums +### Task 1: Freeze the initial v4 schemas and shared enums **Files:** - Create: `schemas/review-presentation-v4.schema.json` @@ -238,7 +242,7 @@ func ParseInspectContract(args []string) (InspectRequest, error) { **Interfaces:** ```ts -export type ViewKind = "evolution" | "decisions" | "sessions" | "usage"; +export type ViewKind = "evolution" | "problems" | "decisions" | "sessions" | "usage"; export type SessionIdentity = Readonly<{ provider: string; sessionId: string }>; export function parseMachineLedgerV4(source: string): MachineLedgerV4; export function parseSessionIndexV1(source: string): SessionIndexV1; @@ -340,7 +344,100 @@ if request.ConfirmMigration { - Create: `docs/session-review/gate-0-evidence.md` - [ ] **Step 1: Run the complete Go and plugin gates:** `go test ./... && go vet ./... && go mod tidy -diff && (cd obsidian-plugin && npm run check)`. -- [ ] **Step 2: Run schema fixture tests on macOS and Windows CI.** Expected: eight valid fixtures accepted identically; invalid fixtures rejected with stable codes. +- [ ] **Step 2: Run the initial schema fixture tests on macOS and Windows CI.** Expected: the initial eight valid fixtures are accepted identically and their invalid fixtures are rejected with stable codes; Task 7 extends this matrix to ten. - [ ] **Step 3: Search for forbidden placeholder tokens:** `rg -n $'\x54\x42\x44|\x54\x4f\x44\x4f|\x46\x49\x58\x4d\x45|\x69\x6d\x70\x6c\x65\x6d\x65\x6e\x74\x20\x6c\x61\x74\x65\x72|\x66\x69\x6c\x6c\x20\x69\x6e\x20\x64\x65\x74\x61\x69\x6c\x73|\x68\x61\x6e\x64\x6c\x65\x20\x65\x64\x67\x65\x20\x63\x61\x73\x65\x73|\x73\x69\x6d\x69\x6c\x61\x72\x20\x74\x6f' schemas internal/reviewv4 internal/sessionindex internal/inspect internal/annotation internal/pricing obsidian-plugin/src/contracts/review-v4.ts` and require no hit. - [ ] **Step 4: Record exact commit, commands, pass counts, fixture list, and known non-Gate-0 work in `docs/session-review/gate-0-evidence.md`.** - [ ] **Step 5: Mark Gate 0 complete in the spec only after every preceding check passes; commit documentation when authorized.** + +--- + +### Task 7: Reopen Gate 0 for conversation-chain and problem-map contracts + +**Files:** +- Create: `schemas/conversation-chain-v1.schema.json` +- Create: `schemas/problem-map-candidate-v1.schema.json` +- Create: `internal/conversationchain/types.go`, `codec.go`, `validate.go`, `codec_test.go` +- Create: `internal/problemmap/types.go`, `candidate_codec.go`, `validate.go`, `validate_test.go` +- Modify: `schemas/review-presentation-v4.schema.json` +- Modify: `internal/reviewv4/types.go`, `validate.go`, `codec_test.go` +- Modify: `schemas/agent-annotation-v1.schema.json` +- Modify: `internal/annotation/types.go`, `validate.go`, `validate_test.go` +- Modify: `internal/cli/contracts.go`, `contracts_test.go` +- Modify: `obsidian-plugin/src/contracts/review-v4.ts`, `src/data/contracts-v4.ts` +- Modify: `obsidian-plugin/tests/contracts-v4.test.ts` +- Create: `testdata/contracts/v4/conversation-chain-v1.{valid,invalid}.json` +- Create: `testdata/contracts/v4/problem-map-candidate-v1.{valid,invalid}.json` +- Create: `obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.{valid,invalid}.json` +- Create: `obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.{valid,invalid}.json` +- Modify: `docs/session-review/gate-0-evidence.md` + +**Interfaces:** + +```go +func conversationchain.Parse([]byte) (Document, error) +func conversationchain.Render(Document) ([]byte, error) +func problemmap.ParseCandidates([]byte) (CandidateStore, error) +func problemmap.RenderCandidates(CandidateStore) ([]byte, error) +func problemmap.ValidateGraph([]reviewv4.ProblemNode) error +func problemmap.PreviewMove(nodes []reviewv4.ProblemNode, problemID, newParentID string) (MovePreview, error) +``` + +- [ ] **Step 1: Add RED fixture parity tests for both new contracts, the expanded presentation and generic Agent annotations.** Assert user/assistant roles only, 4,096-byte persisted excerpts, authenticated source refs, exact chain dependency binding, one primary parent, no cycles, maximum two alternates/related nodes, deterministic candidates with nil Agent run, `missing` conclusions with empty text, and milestone summary annotations that use `confirmed_entity_id` without decision-only fields. + +```go +func TestProblemGraphRejectsCycle(t *testing.T) { + nodes := []reviewv4.ProblemNode{ + {ID: "p-a", PrimaryParentID: ptr("p-b")}, + {ID: "p-b", PrimaryParentID: ptr("p-a")}, + } + if err := problemmap.ValidateGraph(nodes); err == nil { t.Fatal("accepted problem cycle") } +} +``` + +- [ ] **Step 2: Run RED.** + +Run: `go test ./internal/conversationchain ./internal/problemmap ./internal/reviewv4 -count=1 && (cd obsidian-plugin && npx vitest run tests/contracts-v4.test.ts)` + +Expected: FAIL because the new packages, schemas and parser branches do not exist. + +- [ ] **Step 3: Implement strict Go codecs and presentation graph validation.** Reuse the canonical JSON helper and five stable wire error families. Normalize empty collections to arrays, reject hidden-role fields and arbitrary raw tool output keys, and calculate digests after omitting only the digest field. + +```go +func ValidateConclusion(c ClosedLoopConclusion) error { + if c.Kind == ConclusionMissing && c.Text != "" { return contractError("wire_contract_invalid", "missing conclusion contains text") } + if c.Kind != ConclusionMissing && strings.TrimSpace(c.Text) == "" { return contractError("wire_contract_invalid", "conclusion text is required") } + return nil +} +``` + +- [ ] **Step 4: Freeze the CLI grammar.** Add read-only `inspect conversation-chain`, `problems candidates list` and `evolution summary-candidates list`; add CAS write grammars for requested milestone summarization, summary confirmation, problem candidate transition, move and reorder. Reject message cursors without a turn unit, missing target IDs for apply/merge, target IDs on keep/dismiss, and incomplete sibling order arrays. Freeze the per-source read ceiling at 64 KiB and require truncation coverage instead of silent clipping. + +```go +case "conversation-chain": + return parseConversationChainArgs(args[1:]) +case "problems": + return parseProblemArgs(args[1:]) +``` + +- [ ] **Step 5: Mirror both contracts and graph invariants in TypeScript.** Parse the new valid fixtures, reject the same invalid fixtures with the same stable family, and extend `ViewKind` to `"evolution"|"problems"|"decisions"|"sessions"|"usage"`. + +```ts +export function parseConversationChainV1(source: string): ConversationChainV1; +export function parseProblemMapCandidateV1(source: string): ProblemMapCandidateV1; +export function assertProblemGraph(nodes: readonly ProblemNodeV4[]): void; +``` + +- [ ] **Step 6: Run the extended local Gate 0.** + +Run: `gofmt -w internal/conversationchain internal/problemmap internal/reviewv4 internal/cli && go test -p 1 -timeout 5m -count=1 ./... && go vet ./... && go mod tidy -diff && (cd obsidian-plugin && npm run check) && git diff --check` + +Expected: PASS; 10/10 contract fixture pairs have byte-identical Go/TypeScript copies and the ordinary zero-token tests still observe zero Agent starts. + +- [ ] **Step 7: Replace the stale Gate 0 conclusion with exact extension evidence.** Record commit, commands, pass counts, the 10-contract matrix, migration fixtures, and keep the conclusion `LOCAL COMPLETE / WINDOWS CI PENDING` until a pushed commit has a successful native Windows job. + +- [ ] **Step 8: Commit the reopened gate when authorized.** + +```bash +git add schemas internal/conversationchain internal/problemmap internal/reviewv4 internal/cli testdata/contracts/v4 obsidian-plugin/src obsidian-plugin/tests docs/session-review/gate-0-evidence.md +git commit -m "feat: extend v4 contracts for problem context" +``` diff --git a/docs/superpowers/plans/2026-09-04-problem-map-placement.md b/docs/superpowers/plans/2026-09-04-problem-map-placement.md new file mode 100644 index 0000000..baec8d6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-04-problem-map-placement.md @@ -0,0 +1,278 @@ +# Problem Map and Placement Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one human-authoritative project problem tree across all Sessions and providers, with zero-token placement recommendations and explicit confirmation before structural changes. + +**Architecture:** Formal problem nodes live in `review-presentation-v4` and reference private conversation turn units by `(provider, session_id, turn_unit_id)`. A deterministic rules engine creates private `problem-map-candidate-v1` recommendations from explicit structural and evidence signals; an Agent can be requested only for unresolved ambiguity and its digest-bound output remains a candidate. Obsidian renders a five-tab shell with a stable left tree, current path, Q/A evidence detail and a bottom placement drawer. + +**Tech Stack:** Go 1.26, existing HumanPresentation patch/publication flow, canonical JSON and CAS, TypeScript 5.8, Obsidian 1.13, Vitest/jsdom. + +**Spec:** `docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md` + +## Global Constraints + +- Prerequisites: reopened Gate 0 Task 7 and `2026-09-04-conversation-chain-evolution-closure.md` are complete. +- The project has one formal problem graph across Codex, Claude Code and OpenCode; filters affect visibility only, never graph authority. +- Every node has at most one primary parent and two related nodes. Formal graphs are acyclic and persist no canvas coordinates. +- Formal structure changes require user action plus review SHA, graph revision and candidate revision CAS. No free drag writes hierarchy. +- Deterministic placement runs first and costs zero tokens. If it cannot place reliably, the question remains pending; it never launches an Agent automatically. +- `answer_state=execution_verified` does not set `workflow_state=resolved`. Resolution requires explicit user confirmation or accepted project acceptance evidence. +- Candidate merge preserves all source turn references. Human text and ordering edits survive rescans and sync. +- The left rail uses real question sentences only; top-level content categories remain exclusively in the top tab bar. + +## File Structure and Ownership + +- `internal/problemmap/rules.go`: deterministic candidate signals and confidence level. +- `internal/problemmap/store.go`: private candidate revisions and dependency invalidation. +- `internal/problemmap/graph.go`: formal graph invariants, move/merge/reorder operations and previews. +- `internal/presentation/problems.go`: HumanPresentation patch baselines and Markdown projection. +- `internal/cli/problems.go`: fixed read/write command handlers and CAS. +- `obsidian-plugin/src/view/render-problems.ts`: three-pane problem context view and pending drawer. +- `obsidian-plugin/src/state/problem-state.ts`: selected path, filters and pending candidate state. +- `obsidian-plugin/src/view/problem-action-modal.ts`: move/merge/reorder confirmation with affected paths. + +--- + +### Task 1: Generate zero-token placement candidates from explicit evidence + +**Files:** +- Create: `internal/problemmap/rules.go`, `rules_test.go` +- Modify: `internal/conversationchain/types.go` +- Modify: `internal/projectview/reduce.go`, `reduce_test.go` + +**Interfaces:** + +```go +type PlacementInput struct { + ProjectID string + Question string + SourceTurns []reviewv4.SourceTurnRef + Graph []reviewv4.ProblemNode + Evidence []Fact + RuleVersion string +} +func RecommendPlacement(PlacementInput) problemmap.Candidate +``` + +- [ ] **Step 1: Write failing rule tests.** Cover explicit numbered headings, quoted parent question, shared file/symbol/commit/error signature, immediate follow-up, conflicting signals, no signal and provider-neutral duplicate Session IDs. + +```go +func TestNoReliableSignalKeepsQuestionPendingWithoutAgent(t *testing.T) { + got := RecommendPlacement(input("How should this work?", graphWithUnrelatedNodes())) + if got.RecommendedRelation != problemmap.KeepPending || got.RecommendedTargetID != nil { t.Fatalf("invented placement: %+v", got) } + if got.AnalysisMode != problemmap.AnalysisDeterministic || got.AgentRunID != nil { t.Fatalf("started agent: %+v", got) } +} +``` + +- [ ] **Step 2: Define deterministic precedence.** Explicit stable problem ID wins, then document heading/numbering, then quoted question, then exact shared evidence, then immediate follow-up. Conflicting top-rank signals produce `keep_pending`; lower ranks cannot break a conflict. + +- [ ] **Step 3: Run RED.** + +Run: `go test ./internal/problemmap ./internal/projectview -run 'Placement|Rule|Pending' -count=1` + +- [ ] **Step 4: Implement readable grounds and coarse confidence.** Emit `high` only for explicit ID or unambiguous hierarchy, `medium` for at least two independent exact evidence signals, and `low` otherwise. Return one primary target, at most two alternates and two related nodes using stable `(rank, first_proposed_at, id)` sorting. + +- [ ] **Step 5: Run GREEN plus zero-Agent instrumentation.** + +Run: `gofmt -w internal/problemmap internal/projectview && go test ./internal/problemmap ./internal/projectview ./test/zerotoken -count=1` + +- [ ] **Step 6: Commit when authorized.** + +```bash +git add internal/problemmap internal/conversationchain internal/projectview test/zerotoken +git commit -m "feat: recommend problem placement without tokens" +``` + +--- + +### Task 2: Persist candidates and cache optional Agent analysis by dependencies + +**Files:** +- Create: `internal/problemmap/store.go`, `store_test.go`, `agent.go`, `agent_test.go` +- Modify: `internal/agent/codex/run.go`, `run_test.go` +- Create: `internal/cli/problems.go` +- Modify: `internal/cli/run_test.go` + +**Interfaces:** + +```go +type CandidateStore interface { + List(context.Context, string, problemmap.CandidateStatus) ([]problemmap.Candidate, error) + CompareAndSwap(context.Context, problemmap.Candidate, int) error +} +func AnalysisIdentity(projectID, normalizedQuestion, ruleVersion string, dependencies []string) string +func (s *Service) RequestAgentPlacement(context.Context, AgentPlacementRequest) (problemmap.Candidate, error) +``` + +- [ ] **Step 1: Write failing store tests.** Assert stable identity, revision CAS, pending/kept/stale transitions, dependency invalidation, concurrent writers and recovery after atomic-write interruption. + +- [ ] **Step 2: Write failing Agent-cache tests.** The first explicit request invokes the proposal-only Agent once; the same normalized question and sorted dependency digests return the stored run; changed dependency starts one new run; failed/invalid output does not advance the successful dependency set. + +```go +func TestRepeatedAgentPlacementReusesDependencyBoundRun(t *testing.T) { + first := requestPlacement(t, service, requestWithDeps("d2", "d1")) + second := requestPlacement(t, service, requestWithDeps("d1", "d2")) + if first.CandidateID != second.CandidateID || fakeAgent.Starts() != 1 { t.Fatalf("cache miss: %d", fakeAgent.Starts()) } +} +``` + +- [ ] **Step 3: Run RED.** + +Run: `go test ./internal/problemmap ./internal/agent/codex ./internal/cli -run 'Candidate|Placement|Dependency' -count=1` + +- [ ] **Step 4: Implement private canonical storage and stale detection.** Store no candidate in Project/Vault. Validate all referenced turn and problem revisions before list/transition. Use process-safe locking and atomic replacement consistent with existing review-job storage. + +- [ ] **Step 5: Restrict the optional Agent.** Pass only bounded visible question/evidence summaries and the current candidate targets; require strict candidate JSON; reject hidden-role fields, unsupported target IDs, invented source refs and extra prose. + +- [ ] **Step 6: Run full Go gates and commit when authorized.** + +```bash +git add internal/problemmap internal/agent/codex internal/cli +git commit -m "feat: persist problem placement candidates" +``` + +--- + +### Task 3: Apply formal graph changes through HumanPresentation CAS + +**Files:** +- Create: `internal/problemmap/graph.go`, `graph_test.go` +- Create: `internal/presentation/problems.go`, `problems_test.go` +- Modify: `internal/cli/problems.go` +- Create: `internal/cli/problems_test.go` +- Modify: `internal/publication/service.go`, `service_test.go` +- Modify: `internal/syncproject/service.go`, `service_test.go` + +**Interfaces:** + +```go +func ApplyCandidate(graph Graph, candidate Candidate, action ApplyAction, targetID *string) (Graph, error) +func PreviewMove(graph Graph, problemID string, newParentID *string) (MovePreview, error) +func Move(graph Graph, MoveRequest) (Graph, error) +func Reorder(graph Graph, parentID *string, orderedChildIDs []string) (Graph, error) +``` + +- [ ] **Step 1: Write failing graph tests.** Cover child, sibling, merge, keep pending, root move, subtree move, cycle rejection, related-node cleanup, missing/duplicate reorder IDs and stable sibling order. + +```go +func TestMergePreservesEverySourceTurn(t *testing.T) { + got := ApplyCandidate(graphWith("target", refs("codex/s1/t1")), candidate(refs("claude/s2/t2")), Merge, ptr("target")) + assertRefs(t, got.Node("target").SourceTurnRefs, "codex/s1/t1", "claude/s2/t2") +} +``` + +- [ ] **Step 2: Write failing status tests.** Adding verified execution promotes only answer state; resolving requires `ConfirmResolved=true` or an accepted verification reference whose acceptance record explicitly names the problem ID. + +- [ ] **Step 3: Run RED.** + +Run: `go test ./internal/problemmap ./internal/presentation ./internal/cli ./internal/publication ./internal/syncproject -run 'Problem|Graph|Candidate|Reorder' -count=1` + +- [ ] **Step 4: Implement pure graph operations, then wrap them in one locked CAS transaction.** Validate review SHA, graph revision, candidate revision, target revisions and publication preimages before rendering. On any error write no Markdown, ledger, candidate state or sync pointer. + +- [ ] **Step 5: Project the formal tree into existing Markdown.** Add one bounded “问题脉络” block in `项目回顾.md`; keep full evidence private, preserve unknown blocks and use generated baselines/human patches for editable question, conclusion, criterion and state fields. + +- [ ] **Step 6: Run full gates and commit when authorized.** + +Run: `gofmt -w internal/problemmap internal/presentation internal/cli internal/publication internal/syncproject && go test ./... && go vet ./... && go mod tidy -diff` + +```bash +git add internal/problemmap internal/presentation internal/cli internal/publication internal/syncproject +git commit -m "feat: apply human-confirmed problem graph changes" +``` + +--- + +### Task 4: Build the five-tab Obsidian problem context view + +**Files:** +- Create: `obsidian-plugin/src/state/problem-state.ts`, `state/problem-state.test.ts` +- Create: `obsidian-plugin/src/view/render-problems.ts`, `render-problems.test.ts` +- Create: `obsidian-plugin/src/view/problem-action-modal.ts` +- Modify: `obsidian-plugin/src/view/render-shell.ts`, `project-view.ts`, `styles.css` +- Modify: `obsidian-plugin/src/state/store.ts`, `data/repository.ts`, `cli/runner.ts` +- Modify: `obsidian-plugin/tests/view.test.ts`, `accessibility.test.ts`, `styles.test.ts`, `cli.test.ts` + +**Interfaces:** + +```ts +export type ViewKind = "evolution" | "problems" | "decisions" | "sessions" | "usage"; +export function renderProblems(model: BrowserModelV4, state: ViewState, actions: ProblemActions): HTMLElement; +export interface ProblemActions { + selectProblem(id: string): void; + transitionCandidate(request: CandidateTransitionRequest): Promise; + moveProblem(request: MoveProblemRequest): Promise; + reorderChildren(request: ReorderProblemRequest): Promise; + openTurn(ref: SourceTurnRef): Promise; +} +``` + +- [ ] **Step 1: Write failing shell tests.** Require exact tab order `项目演进 / 问题脉络 / 决策与约定 / 全部 Sessions / 用量`, roving tab focus and persistence migration from the old four-tab state. + +- [ ] **Step 2: Write failing tree/layout tests.** The left rail contains only question text and workflow state; selecting a node shows its ancestor path, direct children, up to two related nodes and right-side Q/A chain; collapsed branches keep descendant counts without flattening hierarchy. + +```ts +it("does not duplicate top categories in the problem tree", () => { + const panel = renderProblems(model(), state(), actions()); + const tree = panel.querySelector('[role="tree"]')!; + expect(tree.textContent).not.toContain("决策与约定"); + expect(tree.textContent).not.toContain("模型价格"); +}); +``` + +- [ ] **Step 3: Write failing candidate interaction tests.** Show recommended relation/target, two alternates, related nodes, grounds and confidence. Cover child, sibling, merge, keep pending, stale candidate refresh and CAS conflict without optimistic tree mutation. + +- [ ] **Step 4: Run RED.** + +Run: `cd obsidian-plugin && npx vitest run tests/view.test.ts tests/accessibility.test.ts tests/styles.test.ts tests/cli.test.ts src/state/problem-state.test.ts src/view/render-problems.test.ts` + +- [ ] **Step 5: Implement the stable three-pane layout.** Use semantic `tablist`, `tree`, `treeitem`, headings and buttons; compute indentation from parent relations rather than stored coordinates; show the bottom pending drawer only when a candidate is selected. At narrow width stack the evidence panel below without changing tree order. + +- [ ] **Step 6: Implement confirmation modals.** Move previews show old path, new path and affected subtree. Merge previews list all source turns retained. Reorder submits the complete direct-child ID array. Announce success/error through one polite live region. + +- [ ] **Step 7: Run plugin gates and commit when authorized.** + +```bash +git add obsidian-plugin/src obsidian-plugin/tests +git commit -m "feat: add Obsidian problem context view" +``` + +--- + +### Task 5: Verify cross-Session/provider behavior and real Obsidian acceptance + +**Files:** +- Create: `testdata/problem-map/mixed-provider-project/` +- Create: `test/integration/problem_map_test.go` +- Create: `docs/session-review/problem-map-acceptance.md` +- Modify: `test/zerotoken/gate_a_test.go` + +**Interfaces:** Uses the frozen contracts and public commands from Tasks 1–4; creates no new production interface. + +- [ ] **Step 1: Build a mixed-provider fixture.** Include Codex, Claude Code and OpenCode Sessions sharing native ID `same`; one explicit cross-Session continuation; one similarity-only question; one missing answer; one verified execution; one resolved problem; one stale candidate. + +- [ ] **Step 2: Write the end-to-end test.** Scan twice and assert one stable formal graph, namespaced source refs, explicit continuation linked, similarity-only item pending, zero Agent starts, identical second-run bytes and unchanged human edits. + +```go +func TestMixedProviderProblemMapIsStableAndZeroToken(t *testing.T) { + first := runFixture(t, "mixed-provider-project") + second := runFixture(t, "mixed-provider-project") + if first.AgentStarts != 0 || second.AgentStarts != 0 { t.Fatal("ordinary scan started an agent") } + if !bytes.Equal(first.Review, second.Review) { t.Fatal("problem presentation drifted") } +} +``` + +- [ ] **Step 3: Run repository gates.** + +Run: `go test -p 1 -timeout 5m -count=1 ./... && go vet ./... && go mod tidy -diff && (cd obsidian-plugin && npm run check) && git diff --check`. + +- [ ] **Step 4: Install the current bundle into a disposable real Vault.** Verify the left hierarchy never flattens, top tabs do not overlap it, the focus path and right chain match the selected question, ambiguous placement remains pending, every source opens the correct provider Session, and restart/reopen keeps the same structure. + +- [ ] **Step 5: Exercise all structural actions with undo evidence.** Apply child, sibling and merge; keep one candidate pending; move a subtree; reorder siblings; provoke one stale CAS; sync Project/Vault both directions; confirm source refs and human edits survive. + +- [ ] **Step 6: Record evidence and commit when authorized.** Include build hash, Obsidian version, fixture totals, before/after screenshots, zero-Agent counter, command outputs and any unverified platform boundary. + +```bash +git add testdata/problem-map test/integration test/zerotoken docs/session-review/problem-map-acceptance.md +git commit -m "test: accept project problem map workflow" +``` diff --git a/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md index 533c141..3499087 100644 --- a/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md +++ b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md @@ -1,6 +1,6 @@ # SessionReviewer Obsidian 项目脉络、决策与价格查询设计 -- 状态:Gate 0 本地验收通过;Windows CI 待运行 +- 状态:扩展设计已确认;因新增会话因果链、问题脉络与演进闭环合同,Gate 0 重新打开 - 日期:2026-09-04 - 适用范围:SessionReviewer 零 Token 扫描、macOS/Windows Obsidian Desktop 项目脉络浏览器、Project/Vault 投影 - 扩展:`2026-08-25-session-reviewer-project-evolution-browser-design.md` @@ -13,6 +13,8 @@ SessionReviewer 0.3.0 已将可验证的零 Token 扫描与人类语义总结分 2. 新的零 Token 扫描可以记录发生过的事实,但不会臆造“为什么这样决定”。因此新项目的“关键决策”为空,现有界面却没有说明这是能力边界而非扫描失败。 3. 当前投影可把大量原子事件 ID 直接写进人类可读页面。这保留了索引,但破坏了“打开项目后快速恢复上下文”的产品目标。 4. 模型价格变化频繁,本地固定价格表容易过期;未匹配价格又不应被当作零成本。 +5. 当前“项目演进”把单条用户请求直接投影成节点,右侧只复述问题并填入统一占位文案,看不到 Agent 回答、执行动作或实际验证;这既不是完整会话回顾,也不构成项目里程碑。 +6. 项目讨论通常从一个大问题逐步拆成多个子问题,现有界面没有保存这种细化脉络,也无法建议新问题应归入哪个已确认节点。 ## 2. 产品原则 @@ -24,6 +26,8 @@ SessionReviewer 0.3.0 已将可验证的零 Token 扫描与人类语义总结分 - **AI 只负责候选**:AI 可按用户要求从新 Sessions 提炼决策候选,但不能自动升级为正式项目事实。 - **截断必须显式**:任何列表、分页、摘要或保留策略都必须显示总量、当前范围和未展示数量,禁止静默丢弃。 - **价格可追溯且不阻断扫描**:用量事实与价格查询分离;价格不可用时仍保留 Token 统计,但费用不得伪装为 `$0`。 +- **结构整理零 Token 优先**:问答分段、同 Session 因果绑定、显式引用匹配、执行与验证归属均使用确定性规则;只有用户主动要求处理规则无法判定的语义歧义时才调用 Agent。 +- **只处理可见内容**:人类消息、Agent 可见回答和工具事实可进入因果链;隐藏推理、系统或开发者指令、加密或不透明压缩内容永远不能成为问题、结论或关联依据。 ## 3. Obsidian 信息架构 @@ -39,9 +43,10 @@ SessionReviewer 0.3.0 已将可验证的零 Token 扫描与人类语义总结分 主视图固定为以下顺序: 1. **项目演进** -2. **决策与约定** -3. **全部 Sessions** -4. **用量** +2. **问题脉络** +3. **决策与约定** +4. **全部 Sessions** +5. **用量** 这个顺序先提供恢复判断所需的高层信息,再提供完整证据下钻,最后展示资源使用。 @@ -61,11 +66,76 @@ SessionReviewer 0.3.0 已将可验证的零 Token 扫描与人类语义总结分 项目首页中的原子事件 ID 列表被取消。事实索引通过“全部 Sessions”和私有 Observation Store 保留。 -## 5. 决策与约定 +### 4.1 演进详情的闭环摘要 + +演进节点右侧不再使用“节点意义、摘要、为什么会走到这里、发生了什么、结果与验证、下一步”的空泛模板。固定按以下顺序展示: + +1. 触发问题; +2. Agent 结论; +3. 执行与变更; +4. 结果与验证; +5. 对项目的影响与后续。 + +默认“Agent 结论”来自可见 Agent 最终回答的限长原文摘录,不调用模型重新总结。人工确认或编辑的结论优先于原文摘录;只有用户主动要求整理时才允许 Agent 生成候选摘要,并标记“AI 整理,待确认”,确认后才能成为正式 HumanPresentation。界面提供“查看回答正文”“打开原 Session”“查看关联问题”下钻;回答正文按需从经过哈希认证的原 Session 读取、过滤和脱敏,不持久化到 Vault 或私有派生链。单条源记录最多读取 64 KiB,超限时明确标记截断并以“打开原 Session”作为唯一完整来源。原始工具输出不在此入口返回。 + +确定性结论摘录选择该问答单元最后一条非空可见 Agent 消息;展开时按原顺序显示该单元的全部可见 Agent 消息。如果 Session 在该单元结束前中断、最后消息未完成或 adapter 只能证明稳定前缀,则结论标记“回答可能不完整”,不能展示成已完成答复。 + +一个里程碑可以引用多个 provider-neutral 问答单元,因此最初提问、后续实施和再次验证可以跨 Session 组成闭环。每一段都必须显示真实 `(provider, session_id, turn_unit_id)`、时间和证据类型,不能把后续 Session 的回答伪装成原 Session 回答。回答、执行、验证或来源缺失时保留已有段,并明确显示“未捕获 Agent 回答”“未发现执行证据”“待验证”或“来源不可用”,不得用统一占位文案补齐。 + +### 4.2 旧演进节点迁移 + +v3 自动生成且未被人编辑的 `user_request` 占位节点在显式 v4 迁移时重新分类:有实施、验证、发布或人工确认依据的升级为里程碑并生成闭环摘要;只有提问、没有形成关键项目变化的转入问题脉络。所有源问答和 Session 引用继续保留。 + +人工编辑过的旧演进节点原样保留,并标记 `provenance=migrated`;迁移不得用新规则覆盖其标题或正文。dry-run 必须分别报告“升级为里程碑、转入问题脉络、保留人工节点、无法归类”的数量和稳定 ID,确认迁移后才能发布,且重复执行结果必须一致。 + +## 5. 问题脉络 + +“问题脉络”使用顶部独立 Tab。左侧只显示真实问题句及其父子层级,不重复“项目演进、决策与约定、模型价格”等顶部栏目。主体区域显示当前问题的父路径、直接子问题和有限相关问题;右侧显示所选问题关联的问答与执行因果链;底部抽屉处理待归类问题。 + +项目只有一张跨 Sessions、跨 Codex/Claude Code/OpenCode 的问题图。界面可按 Session、provider 和状态筛选,但筛选不改变图的权威结构。正式图属于 `review-presentation-v4` 的 HumanPresentation;AI 或规则推荐只能进入私有 `problem-map-candidate-v1`,未经用户确认不得改变父子关系。 + +### 5.1 问答单元与因果链 + +最小问答单元从一条可见用户消息开始,到同 Session 的下一条可见用户消息之前结束,包含期间所有可见 Agent 回复、工具调用、工具结果和文件变化引用。系统消息、开发者消息、隐藏推理和不透明压缩内容不参与分段。连续的多条 Agent 消息属于同一单元;没有 Agent 回复的单元合法存在,回答状态为 `no_answer`。 + +同一 Session 内的顺序绑定是确定性的。跨 Session 只在出现稳定问题 ID、显式引用、相同文件/符号/提交/错误签名等可复核信号时自动关联;仅靠语义相似只能生成待确认候选。每个问答单元使用 `(provider, session_id, turn_unit_id)` 身份,不能仅以原生 Session ID 或消息文本去重。 + +### 5.2 正式问题节点 + +每个正式问题节点包含: + +~~~text +id +question +primary_parent_id | null +related_node_ids[] +workflow_state = not_started | in_progress | paused | resolved +answer_state = no_answer | answered_unverified | execution_verified +completion_criterion +current_conclusion +source_turn_refs[] { provider, session_id, turn_unit_id } +provenance = human_created | migrated | candidate_confirmed +first_proposed_at +sibling_order +confirmed_at | null +revision +~~~ + +节点只能有一个主父节点,但最多可以有两个相关节点交叉引用。主父关系必须无环;坐标和临时折叠状态不持久化。兄弟节点默认按首次提出时间和稳定 ID 排序,用户可以显式调整顺序。 + +`execution_verified` 只表示存在受支持的测试、构建、命令退出码、产物或真实环境证据;它不等于项目问题已经解决。`workflow_state=resolved` 必须来自用户确认或已接受的项目验收事实。仅有 Agent 回答且没有验证时显示“已有回答,待验证”。 + +### 5.3 新问题归位建议 + +零 Token 规则先使用编号、标题层级、显式引用、共享文件/符号/提交/错误签名和连续追问关系产生归位建议。建议必须包含一个推荐主父节点、最多两个备选节点、最多两个相关节点、命中的可读依据和 `high|medium|low` 置信等级;不得展示无法复核的伪精确概率。 + +用户可以选择“作为子问题”“作为同级问题”“合并到已有问题”或“继续待归类”。合并必须保留所有源问答引用;移动有子树的节点时,确认对话框显示旧路径、新路径和受影响子树。规则无法可靠归位时默认进入“待归类问题”,不自动调用 Agent。用户主动要求 Agent 协助时,调用结果仍只是候选,并以排序后的 dependency digests 缓存,依赖未变化时不得重复消耗 Token。 + +## 6. 决策与约定 “关键决策”更名为“决策与约定”,并分为两个区域。 -### 5.1 已确认 +### 6.1 已确认 正式条目只能来自: @@ -85,7 +155,7 @@ SessionReviewer 0.3.0 已将可验证的零 Token 扫描与人类语义总结分 默认只展示“生效中”条目。旧决策不物理删除,而是用“已被某决策替代”保留演进关系。 -### 5.2 待确认候选 +### 6.2 待确认候选 零 Token 扫描不自动生成决策候选。只有用户主动点击“从新 Sessions 提炼候选”时,系统才可调用受限 Agent: @@ -102,9 +172,9 @@ Agent 候选保存在私有、依赖绑定的语义注释存储中。Obsidian 并提供“新增决策或约定”与“从新 Sessions 提炼候选”两个入口。项目首页最多展示三条当前生效的决策:人工置顶条目优先,其余按发生时间倒序和稳定 ID 排序。置顶是人类可编辑展示字段,不改变决策身份或证据。 -## 6. 全部 Sessions +## 7. 全部 Sessions -### 6.1 列表完整性 +### 7.1 列表完整性 每个被发现且可归属项目的 Session 必须占据一个索引项。损坏、未完成、部分可读、来源不可用或存在警告的 Session 不得从列表中消失。 @@ -133,7 +203,7 @@ Session 索引是“项目已接受 Session 集合”的累积视图。新世代 默认按时间倒序分组,稳定排序键为 `started_at desc nulls last, provider asc, session_id asc`。日期、来源、处理状态和来源可用性可直接由紧凑索引筛选;分支、文件和错误特征通过受限只读 CLI 查询,避免为搜索而把大量路径或错误文本复制到 Vault。 -### 6.2 Session 索引项 +### 7.2 Session 索引项 每个索引项最少包含: @@ -149,7 +219,7 @@ Session 索引是“项目已接受 Session 集合”的累积视图。新世代 不在 Vault 中默认复制完整原始对话或工具输出。 -### 6.3 下钻与分页 +### 7.3 下钻与分页 点击 Session 后,右侧详情展示该 Session 的阶段、关键操作、验证结果、错误和留下的问题。这些摘要必须是已脱敏且有来源绑定的确定性投影。 @@ -157,7 +227,7 @@ Session 索引是“项目已接受 Session 集合”的累积视图。新世代 每页最多 100 条。界面显示“当前 1–100 / 共 2,438 条”等完整性信息。顺序浏览使用不透明 `previous_cursor` 和 `next_cursor`;跳转首页、末页或指定页时,插件把一基 ordinal 交给 CLI 换取当前世代的页锚点。cursor 必须绑定 project ID、provider、Session ID、generation ID、排序版本、筛选摘要和 page size。任一绑定不一致或世代过期时返回类型化 `stale_cursor`,插件刷新索引后回到最接近的可用位置,不静默读取另一个 Session 或世代。 -### 6.4 投影文件 +### 7.4 投影文件 新增隐藏的: @@ -171,9 +241,9 @@ docs/session-review/.session-reviewer/session-index.json 旧插件可忽略该隐藏文件;新插件在索引缺失时显示“需要重新扫描以建立完整 Session 索引”,不将缺失解释为零个 Sessions。 -## 7. 用量与价格 +## 8. 用量与价格 -### 7.1 展示 +### 8.1 展示 保留每个模型一张横向占满的卡片。卡片展示: @@ -187,7 +257,7 @@ docs/session-review/.session-reviewer/session-index.json 界面明确标注“公开 API 标价估算”,订阅包含量、实际账单折扣、税费与企业协议价不参与计算。 -### 7.2 ModelPriceWatch 查询 +### 8.2 ModelPriceWatch 查询 默认使用 [ModelPriceWatch API](https://modelpricewatch.com/api/) 作为价格主查询目录。受信 CLI 对 `https://modelpricewatch.com/api/v1/models.json` 和 `https://modelpricewatch.com/api/v1/price-history.json` 分别做每 24 小时最多一次的全局缓存刷新,可使用 ETag 或等价条件请求。不按项目或模型频繁请求。 @@ -208,7 +278,7 @@ ModelPriceWatch 的 `provider` 和模型名称不能脱离 listing ID 直接当 公开 API 未提供的计费维度,例如独立 cache-write 价格,必须由官方价格来源或有来源 URL 和生效日期的本地审核补充表提供。不自行推算缺失价格。`price_note` 等非结构化说明只作为审核提示,不由程序解析成计费规则;只要条目依赖尚未结构化支持的上下文档位、区域、批处理、促销或其他条件,就不得自动定价。 -### 7.3 价格快照与降级 +### 8.3 价格快照与降级 价格绑定到每个 Session 的用量记录,并作为不可变历史快照。价格目录更新只影响之后新接受的用量,不追溯重算旧 Session 成本。 @@ -229,20 +299,24 @@ ModelPriceWatch 的 provider + model 精确匹配 无可用价格或只有部分计费维度可定价时,逐维度显示缺失原因。数据合同保存 `known_subtotal_usd`、可空的 `total_cost_usd`、`pricing_complete` 和 `missing_billing_dimensions`;只有 `pricing_complete=true` 时才允许写入总成本。未知价格和未知成本均为 `null`,不得保存为数值零。 -## 8. 端到端数据流 +## 9. 端到端数据流 ~~~text Agent Session 来源 → SourceAdapter 发现与解码 → Observation Store(机器观察事实) → SessionView(单 Session 确定性物化视图) + → conversation-chain-v1(私有可见问答/执行因果链) → ProjectView(项目级归并) ├─→ 项目演进投影 + ├─→ 问题归位规则 → problem-map-candidate-v1 ├─→ session-index-v1 ├─→ 用量记录 → 价格快照 └─→ 受限只读事件查询 -用户人工编辑 ──→ HumanPresentation ──→ 决策与约定 +用户人工编辑 ──→ HumanPresentation ──┬─→ 项目演进闭环 + ├─→ 正式问题图 + └─→ 决策与约定 受限 AI 提炼 ──→ AgentAnnotation ──→ 待确认候选 └─→ 用户确认 → HumanPresentation ~~~ @@ -255,7 +329,7 @@ HumanPresentation > 确定性 ProjectView AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参与正式项目语义的优先级计算。用户确认后会创建 HumanPresentation 条目,不再以 AgentAnnotation 身份覆盖项目。该优先级只适用于人类语义与展示字段,不能改写 Session 身份、时间戳、Token 计数、命令退出码或来源哈希等机器事实。 -## 9. 失败与恢复 +## 10. 失败与恢复 各子系统独立失败,不相互放大: @@ -267,20 +341,25 @@ AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参 - 价格模型歧义:禁止模糊自动匹配,需要审核别名或人工补充。 - Project/Vault 并发编辑:发布预像不一致时进入既有冲突处理,不覆盖人工内容。 - 插件或 CLI 版本过旧:发布绑定 minimum writer/reader 能力,不将新投影静默降级成旧格式。 +- 问答链部分缺失:保留已捕获段和 coverage,明确显示断点,不把相邻 Session 文本强行拼成回答。 +- 问题归位歧义:候选留在待归类区,不改变正式问题图,也不自动启动 Agent。 +- 问题图冲突或成环:整个结构变更 CAS 失败,保留当前正式图和候选,返回旧路径、新路径及冲突修订。 -## 10. 迁移与兼容 +## 11. 迁移与兼容 - v2/v3 已有人工目标、状态、风险、决策和演进节点原样保留,不重新推断其语义。 +- v3 未经人工编辑的 `user_request` 占位演进节点按 4.2 的显式迁移规则重新分类;它们不因“已纳入索引”占位结果而被视为人工语义。 - 现有 v3 `recent-progress` 原子事件列表在下一次成功发布时从人类页面移除,对应观察事实仍在私有存储中可查。 - `项目历史.md` 继续作为无插件时的语义里程碑降级入口,不扩展为全量原子事件库。 - 新的 `session-index-v1` 使用独立隐藏合同,避免只因增加 Session 浏览能力就改写现有人类 Markdown 语义。 - 不支持 Session 索引的旧插件在 v2/v3 数据保持未迁移时仍可解析项目回顾和历史;新插件对缺失索引的旧项目提供重新扫描入口。 - 价格历史不因迁移或目录刷新被追溯重算。 +- 新问题图不保存画布坐标;迁移只建立确认过的层级和源问答引用,无法确定父节点的旧问题进入待归类区。 - 人类 Markdown 的决策字段扩展使用新的 presentation schema;迁移到 v4 后,旧插件属于不受支持的只读组合,不保证能解析新 schema,也不得写入。两个 Markdown 仍可由用户作为普通文档阅读;若需要在升级前获得明确的不兼容提示,应先发布能够识别 `minimum_reader_version` 的桥接版插件。 -## 11. 验证策略 +## 12. 验证策略 -### 11.1 单元与合同测试 +### 12.1 单元与合同测试 - Session 索引稳定排序、身份唯一性、世代绑定和 coverage 统计; - 四种处理状态严格分区、未知值不伪装为零、来源消失后索引累积保留; @@ -294,8 +373,14 @@ AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参 - 分档、区域、批处理、跨价格边界和缺少 cache-write 等条件不能被静默简化; - 价格快照在后续目录变更后保持字节不变; - 未定价成本不被纳入“完整总成本”。 +- 问答单元边界、连续多 Agent 消息、无回答、工具调用/结果归属和超长可见回答分块; +- 隐藏推理、系统/开发者指令和原始工具输出不能进入 conversation chain; +- 问题图单主父、无环、兄弟稳定排序、相关节点上限、状态正交和 source turn identity 唯一性; +- 归位建议主推荐、两个备选、两个相关节点、可读依据和置信等级的封闭合同; +- 同 dependency digest 重复整理不得启动 Agent,依赖变化后旧候选必须 stale; +- 闭环摘要只有具备明确里程碑资格时才进入项目演进,缺段不得生成占位结论。 -### 11.2 集成与性能测试 +### 12.2 集成与性能测试 - 单个 Session 含数千事件,可访问第一条、末条和中间页; - 单项目含至少 154 个 Sessions,顶部总数与列表数量一致; @@ -304,12 +389,15 @@ AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参 - 网络断开、HTTP 429、超时和无匹配模型不影响扫描世代提交; - Project/Vault 在扫描期间并发编辑时,预像检查拒绝覆盖人工修改; - v2、现有 v3 和全新项目的迁移、重扫和恢复路径。 +- 同一问题跨三个 provider、多个 Session 的显式引用拼接,以及只有语义相似时保持候选; +- 旧自动 `user_request` 节点升级、转入问题脉络、保留人工节点和重复迁移幂等; +- 普通零 Token 扫描的 Agent 子进程调用次数严格为零。 -### 11.3 真实 Obsidian 验收 +### 12.3 真实 Obsidian 验收 每次界面或合同修改后,都必须安装当前构建包到真实 Vault 并验证: -1. 四个标签顺序正确; +1. 五个标签顺序正确; 2. 项目演进默认简洁且可打开全部里程碑; 3. Session 总数、异常数、列表和最后一个 Session 一致; 4. 超长 Session 分页无静默缺口; @@ -317,10 +405,13 @@ AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参 6. 价格日期、数据页、官方来源、促销或待定状态可读; 7. 插件重启、Vault 重开和同步后状态不丢失; 8. 无 CLI、无网络和来源消失的降级提示准确。 +9. 第一个演进节点右侧可见 Agent 回答摘录、执行、验证和来源,或明确说明对应段未捕获; +10. 问题树左侧只显示真实问题及层级,待归类问题不会未经确认进入主树; +11. 键盘可以选择问题、展开闭环证据、确认归位和返回原 Session。 无 CLI 时,“全部 Sessions”仍必须显示 `session-index-v1` 中的完整清单和基础筛选;仅 Session 摘要、深层事件和分支/文件/错误搜索被禁用,并给出安装或配置 CLI 的单一恢复入口。 -## 12. 验收条件 +## 13. 验收条件 交付必须同时满足: @@ -334,10 +425,15 @@ AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参 8. 历史费用使用接受时价格快照,后续价格刷新不修改旧记录。 9. 价格服务不可用时扫描仍成功,费用以“待定”降级而不是 `$0`。 10. v2/v3 已有人工内容、决策替代关系和历史价格不因升级丢失或被重算。 -11. 已启用的 Codex、Claude Code 和 OpenCode Sessions 在同一四栏界面中具有相同的索引、下钻、状态和降级体验。 +11. 已启用的 Codex、Claude Code 和 OpenCode Sessions 在同一五栏界面中具有相同的索引、下钻、状态和降级体验。 12. Session 来源消失后,其已接受索引和摘要仍可见;恢复来源后,新世代能重新关联而不产生重复 Session。 +13. 项目演进只包含合格里程碑;右侧闭环摘要按“触发问题、Agent 结论、执行与变更、结果与验证、项目影响与后续”展示并可追溯。 +14. 默认 Agent 结论来自可见回答的确定性摘录;普通扫描不调用 Agent,AI 整理未经确认不能进入正式表现。 +15. 正式问题图跨 Sessions 和 provider 合并,但每个节点只有一个主父节点,模糊归位只进入待确认候选。 +16. `execution_verified` 不自动把问题标为 `resolved`;问题解决需要用户确认或已接受验收事实。 +17. v3 自动占位节点迁移可预览、可核对、幂等且不覆盖任何人工编辑。 -## 13. 非目标 +## 14. 非目标 - 不把全部原始 Session 文本或完整工具输出复制到 Vault。 - 不让零 Token 规则自动推断意图、理由或正式决策。 @@ -345,23 +441,30 @@ AgentAnnotation 在被确认前只出现在“待确认候选”区域,不参 - 不将 ModelPriceWatch 价格视为用户真实账单或不可复核的唯一真相。 - 不在本设计中实现实际账单对账、订阅额度扣减或企业合同价。 - 不增加用户可见的项目文档数量。 +- 不保存、分析或展示隐藏推理、系统提示词或开发者指令。 +- 不把每个问答单元都提升为项目演进节点。 +- 不允许自由拖拽直接改写问题层级;结构变更必须经过明确动作和 CAS 确认。 -## 14. 建议实施边界 +## 15. 建议实施边界 -实施计划先完成合同与基线 Gate 0,再分成可独立验证的四组: +实施计划先完成合同与基线 Gate 0,再分成可独立验证的六组: 0. 固定 0.3.5 v3 实施基线,落地 schema、CLI、状态机、版本矩阵和迁移夹具; 1. `session-index-v1` 生成、发布、同步和只读分页查询; -2. Obsidian 四视图顺序、全部 Sessions 列表与超长 Session 下钻; +2. Obsidian 五视图顺序、全部 Sessions 列表与超长 Session 下钻; 3. 决策与约定的空状态、人工新增、AI 候选与确认转换; 4. ModelPriceWatch 目录缓存、精确匹配、价格快照和用量卡片状态。 +5. `conversation-chain-v1`、跨 Session 因果拼接和项目演进闭环摘要; +6. 正式问题图、`problem-map-candidate-v1`、归位建议和待确认交互。 + +六组共享 Gate 0 固定的合同与世代身份,不得在 UI 实施过程中临时改变核心 schema。每组都必须在进入下一组前通过单元测试、集成测试和针对性真实 Obsidian 验收。本文件是总设计;六组分别使用独立实施计划,不合并成一个难以评审和回滚的巨型计划。 -四组共享 Gate 0 固定的合同与世代身份,不得在 UI 实施过程中临时改变核心 schema。每组都必须在进入下一组前通过单元测试、集成测试和针对性真实 Obsidian 验收。本文件是总设计;四组分别生成实施计划,不合并成一个难以评审和回滚的巨型计划。 +依赖顺序固定为:扩展 Gate 0 完成后,Session 索引发布与会话因果链可以独立实施;问题脉络依赖会话因果链;“全部 Sessions”最终界面整合依赖 Session 索引和问题脉络;决策与约定依赖五 Tab shell;价格服务依赖 Session 索引,价格 UI 依赖五 Tab shell。未满足前置门禁时不得用占位数据宣称对应功能完成。 -## 15. 实施基线与版本合同 +## 16. 实施基线与版本合同 -### 15.1 基线 +### 16.1 基线 项目所有者在实施前确认使用远端最新发布标签 `0.3.5`(`ea5b1ba`)的零 Token v3 架构作为唯一实现基线,以保留 0.3.1–0.3.5 的扫描、Windows 和发布恢复修复。原工作区中的回退、删除或未完成跨版本修改保留原状;实现只在隔离分支 `codex/obsidian-context-v4` 中进行,不覆盖这些既有修改。 @@ -372,21 +475,24 @@ Gate 0 固定以下版本边界: - `session-index-v1`:完整 Session 紧凑索引; - `session-summary-v1`:单 Session 的确定性、已脱敏详情响应; - `session-event-page-v1`:Observation Store 的分页读取响应; -- `agent-annotation-v1`:私有候选决策与提炼运行状态; +- `agent-annotation-v1`:私有候选决策、约定或里程碑结论摘要及其提炼运行状态; - `pricing-snapshot-v1`:不可变价格快照,作为 `machine-ledger-v4` 的受校验成员。 - `pricing-supplement-v1`:人工补价/纠错的受限标准输入合同;服务端计算费用,不持久化调用方提交的计算结果。 +- `conversation-chain-v1`:私有、provider-neutral 的单 Session 问答单元与执行因果链,绑定 SessionView dependency digest;不写入 Vault。 +- `problem-map-candidate-v1`:私有问题归位候选与可复核规则依据;未经确认不进入 HumanPresentation。 +- `review-presentation-v4` 增加正式 `problem_nodes[]`、问题图修订和演进 `closed_loop`;继续使用两个 Markdown,不增加第三个用户可见文档。 -每个持久化合同必须同时提供 JSON Schema、Go 运行时校验、TypeScript 解析器、有效/无效 fixture 和规范化字节测试。新增字段不得只依赖 TypeScript 类型或 UI 判空。 +每个持久化合同必须同时提供 JSON Schema、Go 运行时校验、TypeScript 解析器、有效/无效 fixture 和规范化字节测试。新增字段不得只依赖 TypeScript 类型或 UI 判空。由于这些合同不在已完成的八组 fixture 中,原 Gate 0 本地证据不再覆盖当前完整设计;必须扩展并重跑 Gate 0,Windows 原生 CI 仍需在推送后单独验证。 -### 15.2 Provider 范围 +### 16.2 Provider 范围 上述合同必须是 provider-neutral:`provider` 使用受限 safe ID,不在通用 schema 中写死为 `codex`。已启用的 Codex、Claude Code 和 OpenCode SourceAdapter 使用相同的 Session 索引、状态、分页和 Obsidian 表现合同;某个 provider 尚未安装或不兼容时,以来源级诊断呈现,不能让其他 provider 的 Session 消失。 本设计不重新定义三种 SourceAdapter 的解码细节。若实施基线尚未包含 Claude Code 或 OpenCode Adapter,它们是对应端到端验收的前置工作,不能通过在 UI 中显示 provider 名称冒充同等支持。 -## 16. 持久化合同 +## 17. 持久化合同 -### 16.1 `session-index-v1` +### 17.1 `session-index-v1` 顶层至少包含: @@ -456,7 +562,7 @@ last_successful_generation_id | null 所有数组有明确最大项数,所有字符串有 UTF-8 字节上限。`state_reason_codes` 只能使用版本化枚举;用户可见说明由插件本地化,机器文件中不保存任意错误文本。 -### 16.2 Session 摘要 +### 17.2 Session 摘要 `session-summary-v1` 不写入 Vault,由 CLI 从当前 SessionView 和其依赖生成。它包含: @@ -470,7 +576,65 @@ last_successful_generation_id | null 每个区块最多 32 项,每项正文最多 512 UTF-8 字节,按 `occurred_at asc, sequence asc, revision_id asc` 稳定排序。超出部分保存总数和未展示数。摘要只能使用确定性规则和受限脱敏 excerpt;不得生成原因、意图或未被事实支持的“下一步”。规则 ID、规则版本和依赖摘要进入响应,以便重现。 -### 16.3 文件所有权与发布 +### 17.3 会话因果链与问题图合同 + +`conversation-chain-v1` 是私有派生记录,按 `(project_id, provider, session_id, session_view_digest)` 唯一绑定,至少包含: + +~~~text +schema_version +project_id +provider +session_id +session_view_digest +dependency_digest +segmentation_rule_version +coverage { source_messages, captured_messages, turn_units, unanswered_units, truncated_messages } +turn_units[] { + turn_unit_id + ordinal + started_at + ended_at | null + user_message { revision_id, source_ref, occurred_at, visible_excerpt, truncated } + assistant_messages[] { revision_id, source_ref, occurred_at, visible_excerpt, truncated } + actions[] { revision_id, kind, tool_name | null, excerpt } + results[] { revision_id, kind, verification_state, excerpt } + answer_state +} +~~~ + +可见用户和 Agent 摘录最多 4,096 UTF-8 字节;超限必须设置 `truncated=true`、增加 `truncated_messages` 并在 UI 明示。需要正文时,受限查询通过 `source_ref` 调用 SourceAdapter 的认证读取能力,每条最多 64 KiB,读取后只解码对应可见 user/assistant 正文并再次脱敏,不保存查询结果。工具调用和结果只保存受限脱敏 excerpt 与 revision ID,不保存原始高熵输出。链的生成完全确定性;任一输入 revision、规则版本或脱敏版本变化都必须改变 dependency digest。 + +`review-presentation-v4.timeline[].closed_loop` 至少包含触发问题、结论表现类型、结论正文、执行摘要、验证摘要、项目影响、后续、`source_turn_refs[]` 和分段 coverage。`conclusion_kind` 固定为 `visible_answer_excerpt|human_confirmed|ai_candidate_confirmed|missing`;`missing` 时正文必须为空并提供类型化缺失原因。只有 `human_confirmed` 和 `ai_candidate_confirmed` 可表达不能从可见原文直接得出的语义。 + +`agent-annotation-v1` 增加 `annotation_kind=decision_candidate|agreement_candidate|milestone_conclusion_candidate` 和通用的 `confirmed_entity_id|null`。里程碑结论候选必须引用目标 milestone ID、source turn dependencies 和 prompt schema version;确认时只 patch 对应 `closed_loop.conclusion` 并将 `conclusion_kind` 设为 `ai_candidate_confirmed`,不得顺带修改验证、影响、下一步或问题状态。 + +`review-presentation-v4.problem_nodes[]` 使用 5.2 的正式节点字段。图校验必须证明:ID 唯一、父节点存在、无环、根节点集合与空父节点一致、每个相关节点存在且不自指、相关节点不超过两个、source turn refs 存在于当前或保留的 chain dependency 中、同级 `sibling_order` 唯一且稳定。 + +`problem-map-candidate-v1` 是私有 CAS 记录,至少包含: + +~~~text +candidate_id +project_id +question +source_turn_refs[] +recommended_relation = child | sibling | merge | keep_pending +recommended_target_id | null +alternate_target_ids[] +related_node_ids[] +grounds[] { rule_id, rule_version, matched_fact_refs[], explanation } +confidence = high | medium | low +status = pending | applied | merged | kept_pending | stale | dismissed +dependency_digests[] +analysis_mode = deterministic | agent_requested +agent_run_id | null +revision +created_at +updated_at +~~~ + +备选节点和相关节点各不超过两个。`analysis_mode=deterministic` 时 `agent_run_id` 必须为 `null`;`agent_requested` 必须引用一个受限 Agent run。相同排序 dependency digests、规则版本和问题规范化文本产生同一候选身份,重复整理不得重复调用 Agent。 + +### 17.4 文件所有权与发布 | 产物 | 权威写入方 | Project | Vault | 人工可编辑 | 发布事务 | |---|---|---:|---:|---:|---:| @@ -479,18 +643,20 @@ last_successful_generation_id | null | `.session-reviewer/ledger.json` | 受信 CLI | 是 | 是 | 否 | 是 | | `.session-reviewer/session-index.json` | 扫描投影器 | 是 | 是 | 否 | 是 | | Observation Store | 扫描引擎 | 私有 | 否 | 否 | 扫描世代事务 | -| AgentAnnotation Store | 决策候选服务 | 私有 | 否 | 否 | 独立 CAS | +| Conversation Chain Store | 确定性链生成器 | 私有 | 否 | 否 | 扫描世代事务 | +| AgentAnnotation Store | 受限语义候选服务 | 私有 | 否 | 否 | 独立 CAS | +| Problem Map Candidate Store | 归位建议服务 | 私有 | 否 | 否 | 独立 CAS | | 全局价格目录缓存 | 价格服务 | 平台用户缓存 | 否 | 否 | 原子缓存刷新 | 一次扫描发布的原子集合是两个 Markdown、`ledger.json` 和 `session-index.json`。journal 必须保存四者的目标哈希、预像哈希、临时文件和恢复阶段;任一写入、同步或发布后校验失败时,不能暴露混合世代。 人工编辑或候选确认的发布集合是两个 Markdown 和 `ledger.json`;事务开始前必须验证 `session-index.json` 仍绑定预期 generation,但无需重写其规范字节。普通 Project/Vault sync 比较和验证全部四个文件,机器文件仍只允许 Project 权威副本单向发布。 -AgentAnnotation Store 和全局价格目录缓存不属于 Project/Vault 同步集合。候选确认会创建 HumanPresentation patch,随后才通过正常发布事务进入 Markdown 和 ledger。价格目录只是输入缓存;一旦价格被接受,`pricing-snapshot-v1` 作为 `machine-ledger-v4.pricing_snapshots[]` 成员随机器账本发布,之后不依赖缓存继续存在。 +Conversation Chain Store、AgentAnnotation Store、Problem Map Candidate Store 和全局价格目录缓存不属于 Project/Vault 同步集合。问题归位、决策候选或里程碑结论候选确认会创建 HumanPresentation patch,随后才通过正常发布事务进入 Markdown 和 ledger。价格目录只是输入缓存;一旦价格被接受,`pricing-snapshot-v1` 作为 `machine-ledger-v4.pricing_snapshots[]` 成员随机器账本发布,之后不依赖缓存继续存在。 -## 17. 状态机与 CLI 合同 +## 18. 状态机与 CLI 合同 -### 17.1 Session 状态机 +### 18.1 Session 状态机 ~~~text discovered/unprocessed @@ -503,7 +669,7 @@ source_available ⇄ source_unavailable 处理状态是某个世代的结果,不在同一世代原地回退;重扫产生新世代。来源可用性可以在保留旧处理结果的前提下变化。失败或取消且未成功发布的扫描不改变当前有效索引。 -### 17.2 决策候选状态机 +### 18.2 决策候选状态机 候选状态固定为: @@ -516,7 +682,7 @@ ignored ────────────→ stale ~~~ - `confirmed`、`not_decision` 和 `stale` 是该候选修订的终态; -- 确认后创建新的 HumanPresentation 决策,候选只保存其 `confirmed_decision_id`,不再参与正式展示优先级; +- 确认后创建或 patch 对应 HumanPresentation 实体,候选只保存通用 `confirmed_entity_id`,不再参与正式展示优先级;决策/约定候选创建新条目,里程碑结论候选只能修改目标闭环的结论字段; - 对正式决策的后续修改创建 HumanPresentation 新修订,不回写候选正文; - `ignored` 默认隐藏但允许用户恢复; - `not_decision` 持久保留,阻止相同提炼运行再次提出同一候选; @@ -545,7 +711,29 @@ revision 替代关系必须无环;`status=superseded` 时至少存在一个后继条目直接引用该条目,后继自身可以在以后继续被替代。迁移无法恢复的新增字段使用空值、空数组或 `false`,并保留 `provenance=migrated`,不得推断理由或关系。 -### 17.3 只读 CLI +### 18.3 问题节点与归位候选状态机 + +正式问题节点的两个状态维度正交: + +~~~text +workflow_state: not_started ⇄ in_progress ⇄ paused → resolved +answer_state: no_answer → answered_unverified → execution_verified +~~~ + +重新打开已解决问题需要显式人工操作并创建新修订;新的执行证据可以提升 `answer_state`,但不得自动设置 `workflow_state=resolved`。归位候选状态固定为: + +~~~text +pending ─→ applied + ├─────→ merged + ├─────→ kept_pending ─→ pending + ├─────→ dismissed + └─────→ stale +kept_pending ───────────→ stale +~~~ + +`applied` 表示已按 child 或 sibling 关系写入正式图;`merged` 必须把候选的全部 source turn refs 并入目标节点;`stale` 表示任一依赖、目标节点修订或规则版本变化。任何应用、移动、合并或排序操作都验证 presentation 预像、问题图修订和候选修订,失败时不产生部分写入。 + +### 18.4 只读 CLI 插件只允许以 `shell=false` 和固定参数数组调用以下只读合同: @@ -567,6 +755,18 @@ session-reviewer inspect session-search session-reviewer decisions candidates list --project-id [--status ] --json + +session-reviewer evolution summary-candidates list + --project-id --milestone-id [--status ] --json + +session-reviewer inspect conversation-chain + --project-id --provider --session-id + --expected-generation-id + [--turn-unit-id ] [--message-cursor ] + --limit <1..64> --json + +session-reviewer problems candidates list + --project-id [--status ] --json ~~~ `session-search` 只返回匹配的 `(provider, session_id)`、命中类型、总数和分页 cursor;`query` 最大 256 UTF-8 字节,只参与规范化文本匹配,永远不作为文件系统路径解析。无 CLI 时插件禁用分支、文件和错误特征筛选,同时保留索引内的日期、来源和状态筛选。 @@ -595,7 +795,9 @@ coverage 事件项只包含类型化字段、有限脱敏 excerpt、revision ID、sequence 和 occurred_at。CLI 不返回原始系统/开发者指令、隐藏推理、令牌、绝对路径或未脱敏工具输出。cursor 最大长度、响应最大字节数和执行超时必须进入合同测试。 -### 17.4 写入与异步 CLI +`conversation-chain` 默认返回问答单元索引和有限摘录;指定 `--turn-unit-id` 后按需从认证 source refs 读取该单元的可见人类/Agent 正文、动作和结果。`--message-cursor` 只用于同一问答单元的后续可见消息,绑定 project、provider、session、generation、turn unit、脱敏版本和 limit;绑定不符返回 `stale_cursor`。每条源读取仍受 64 KiB 上限和总响应上限约束,超限必须返回 coverage。即使私有源包含其他角色,该命令也只能返回 user/assistant 可见正文和受限工具摘要。 + +### 18.5 写入与异步 CLI 写操作固定为: @@ -622,13 +824,39 @@ session-reviewer pricing supplement --project-id --provider --session-id --usage-record-digest --expected-ledger-sha256 --json + +session-reviewer evolution summarize + --project-id --milestone-id + --expected-generation-id --json + +session-reviewer evolution summary-candidate transition + --project-id --milestone-id --candidate-id + --expected-candidate-revision --expected-review-sha256 + --action --json + +session-reviewer problems candidate transition + --project-id --candidate-id + --expected-candidate-revision --expected-problem-map-revision + --expected-review-sha256 + --action + [--target-problem-id ] --json + +session-reviewer problems move + --project-id --problem-id --new-parent-id + --expected-problem-map-revision --expected-review-sha256 --json + +session-reviewer problems reorder + --project-id --parent-id + --expected-problem-map-revision --expected-review-sha256 --json ~~~ `create`、带编辑内容的 `confirm` 和 `pricing supplement` 从标准输入读取最大 64 KiB 的版本化 JSON,不接受用户指定文件路径。补价输入使用 `pricing-supplement-v1`,必须完整声明计费路由、适用时间、可空费率、来源 URL、审计理由,以及可选的 `supersedes_snapshot_id`;服务端重新计算 billable quantities、line costs、subtotal 和 total,拒绝插件直接提交计算结果。`extract` 使用 SessionReviewer 已配置并验证的 proposal-only Agent,不接受 Markdown、候选正文或插件传入的任意可执行文件;它返回 job ID,状态查询复用现有受限异步任务模式。 所有写命令在修改前重新验证 project、generation、candidate revision 和 review 预像。CAS 失败返回当前摘要和类型化错误,不覆盖较新的扫描或人工编辑。任何候选或价格失败都不得推进扫描 generation。 -### 17.5 价格状态机与快照 +`problems reorder` 从标准输入读取该父节点下每个直接子节点 ID 恰好一次的完整有序数组;缺失、重复、外来节点或并非直接子节点均拒绝。`problems move` 在应用前返回旧路径、新路径和受影响子树摘要供插件确认;服务端重新验证无环和 related-node 上限。界面不得用自由拖拽绕过这些命令。 + +### 18.6 价格状态机与快照 价格解析状态固定为: @@ -683,7 +911,7 @@ audit_reason 目录刷新不修改快照。补价或纠错创建新快照,并通过 `supersedes_snapshot_id` 指向旧快照;聚合只选择每条用量的最新有效快照,但审计视图可以查看完整链。ModelPriceWatch、官方来源和人工补充的优先级不覆盖适用条件检查:任何条件不明都先进入 `pending` 或 `ambiguous`。 -## 18. 兼容与迁移矩阵 +## 19. 兼容与迁移矩阵 | 项目数据 | CLI | 插件 | 行为 | |---|---|---|---| @@ -718,5 +946,8 @@ dry-run 返回版本化迁移预览、将保留或补默认值的语义单元、 5. 旧价格保留为迁移快照,无法证明来源或日期时标为 `legacy_unverified`,不重算; 6. 迁移备份和 journal 遵循既有私有路径、原子替换和恢复规则; 7. 迁移后连续两次 render、sync 和重启不得产生字节、哈希或 revision 漂移。 +8. dry-run 分别列出自动 `user_request` 节点中升级为里程碑、转入问题脉络、保留人工编辑和无法归类的稳定 ID;确认迁移后才移除旧占位文案。 +9. 迁移建立的正式问题节点不推断父子关系;只有明确旧层级或人工确认关系可以进入主树,其余进入待归类候选。 +10. `conversation-chain-v1` 从当前 SessionView dependencies 重建,不把旧 Markdown 的摘要反向当成 Agent 原文。 -Gate 0 完成标准是:上述所有 schema、状态枚举、CLI allowlist、兼容 fixture 和失败码均已固定并通过合同测试。只有此后才能进入四个功能实施计划。 +Gate 0 完成标准是:上述所有 schema、状态枚举、CLI allowlist、兼容 fixture 和失败码均已固定并通过合同测试。原八组 fixture 的本地通过记录仍是历史证据,但不能覆盖新增合同;扩展 Gate 0 和 Windows 原生 CI 通过后,才能进入六个功能实施计划。 From e3ff49beb6cb28d4aacb73a5ba4f45c43289b112 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 01:12:10 +0800 Subject: [PATCH 16/25] feat: extend v4 contracts for problem context --- internal/annotation/types.go | 9 +- internal/annotation/validate.go | 32 +- internal/annotation/validate_test.go | 35 +- internal/cli/contracts.go | 307 ++++++++++- internal/cli/contracts_test.go | 100 ++++ internal/conversationchain/codec.go | 89 ++++ internal/conversationchain/codec_test.go | 122 +++++ internal/conversationchain/types.go | 83 +++ internal/conversationchain/validate.go | 109 ++++ internal/memory/api_compat_test.go | 28 +- internal/migrationv4/migrate.go | 3 +- internal/problemmap/candidate_codec.go | 95 ++++ internal/problemmap/types.go | 80 +++ internal/problemmap/validate.go | 246 +++++++++ internal/problemmap/validate_test.go | 143 +++++ internal/reviewv4/codec_test.go | 80 ++- internal/reviewv4/types.go | 113 +++- internal/reviewv4/validate.go | 277 +++++++++- obsidian-plugin/src/contracts/review-v4.ts | 163 +++++- obsidian-plugin/src/data/contracts-v4.ts | 504 +++++++++++++++++- obsidian-plugin/tests/contracts-v4.test.ts | 73 ++- .../v4/agent-annotation-v1.valid.json | 17 +- .../v4/conversation-chain-v1.invalid.json | 17 + .../v4/conversation-chain-v1.valid.json | 32 ++ .../v4/problem-map-candidate-v1.invalid.json | 12 + .../v4/problem-map-candidate-v1.valid.json | 13 + .../v4/review-presentation-v4.invalid.json | 2 +- .../v4/review-presentation-v4.valid.json | 2 +- schemas/agent-annotation-v1.schema.json | 56 +- schemas/conversation-chain-v1.schema.json | 56 ++ schemas/problem-map-candidate-v1.schema.json | 39 ++ schemas/review-presentation-v4.schema.json | 17 +- .../.session-reviewer/ledger.json | 2 +- ...71\347\233\256\345\233\236\351\241\276.md" | 2 +- .../.session-reviewer/ledger.json | 2 +- ...71\347\233\256\345\233\236\351\241\276.md" | 2 +- .../v4/agent-annotation-v1.valid.json | 17 +- .../v4/conversation-chain-v1.invalid.json | 17 + .../v4/conversation-chain-v1.valid.json | 32 ++ .../v4/problem-map-candidate-v1.invalid.json | 12 + .../v4/problem-map-candidate-v1.valid.json | 13 + .../v4/review-presentation-v4.invalid.json | 2 +- .../v4/review-presentation-v4.valid.json | 2 +- 43 files changed, 2977 insertions(+), 80 deletions(-) create mode 100644 internal/conversationchain/codec.go create mode 100644 internal/conversationchain/codec_test.go create mode 100644 internal/conversationchain/types.go create mode 100644 internal/conversationchain/validate.go create mode 100644 internal/problemmap/candidate_codec.go create mode 100644 internal/problemmap/types.go create mode 100644 internal/problemmap/validate.go create mode 100644 internal/problemmap/validate_test.go create mode 100644 obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.valid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.invalid.json create mode 100644 obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.valid.json create mode 100644 schemas/conversation-chain-v1.schema.json create mode 100644 schemas/problem-map-candidate-v1.schema.json create mode 100644 testdata/contracts/v4/conversation-chain-v1.invalid.json create mode 100644 testdata/contracts/v4/conversation-chain-v1.valid.json create mode 100644 testdata/contracts/v4/problem-map-candidate-v1.invalid.json create mode 100644 testdata/contracts/v4/problem-map-candidate-v1.valid.json diff --git a/internal/annotation/types.go b/internal/annotation/types.go index 17998c5..14f3e7a 100644 --- a/internal/annotation/types.go +++ b/internal/annotation/types.go @@ -21,8 +21,9 @@ type StoreRecord struct { type Annotation struct { ID string `json:"id" required:"true"` ProjectID string `json:"project_id" required:"true"` - EntityID string `json:"entity_id" required:"true"` - Field string `json:"field" required:"true"` + AnnotationKind string `json:"annotation_kind" required:"true"` + EntityID *string `json:"entity_id,omitempty"` + Field *string `json:"field,omitempty"` Status CandidateStatus `json:"status" required:"true"` Text string `json:"text" required:"true"` GenerationID string `json:"generation_id" required:"true"` @@ -32,7 +33,9 @@ type Annotation struct { Dependencies []Dependency `json:"dependencies" required:"true"` Revision int `json:"revision" required:"true"` CreatedAt string `json:"created_at" required:"true"` - ConfirmedDecisionID *string `json:"confirmed_decision_id" required:"true" nullable:"true"` + ConfirmedEntityID *string `json:"confirmed_entity_id" required:"true" nullable:"true"` + TargetMilestoneID *string `json:"target_milestone_id,omitempty"` + PromptSchemaVersion *string `json:"prompt_schema_version,omitempty"` } type Dependency struct { diff --git a/internal/annotation/validate.go b/internal/annotation/validate.go index 71dc602..5c23692 100644 --- a/internal/annotation/validate.go +++ b/internal/annotation/validate.go @@ -46,43 +46,63 @@ func Validate(store StoreRecord) error { } annotations := make(map[string]struct{}, len(store.Annotations)) for index, annotation := range store.Annotations { - if annotation.SchemaVersion != 1 || annotation.ProjectID != store.ProjectID || !validID(annotation.ID) || !validID(annotation.EntityID) || !validID(annotation.Field) || !validID(annotation.GenerationID) || !validID(annotation.AnalysisProfile) || !validID(annotation.AgentRunID) || !validText(annotation.Text, 4096) || annotation.Revision < 1 || !validText(annotation.CreatedAt, 128) || len(annotation.Dependencies) > 256 { + if annotation.SchemaVersion != 1 || annotation.ProjectID != store.ProjectID || !validID(annotation.ID) || !validID(annotation.GenerationID) || !validID(annotation.AnalysisProfile) || !validID(annotation.AgentRunID) || !validText(annotation.Text, 4096) || annotation.Revision < 1 || !validText(annotation.CreatedAt, 128) || len(annotation.Dependencies) > 256 { return fmt.Errorf("invalid annotation %d", index) } if _, exists := annotations[annotation.ID]; exists { return fmt.Errorf("duplicate annotation %q", annotation.ID) } annotations[annotation.ID] = struct{}{} + switch annotation.AnnotationKind { + case "decision_candidate", "agreement_candidate": + if !validOptionalID(annotation.EntityID) || !validOptionalID(annotation.Field) || annotation.EntityID == nil || annotation.Field == nil || annotation.TargetMilestoneID != nil || annotation.PromptSchemaVersion != nil { + return fmt.Errorf("decision or agreement candidate %q has invalid conditional fields", annotation.ID) + } + case "milestone_conclusion_candidate": + if annotation.EntityID != nil || annotation.Field != nil || !validRequiredOptionalID(annotation.TargetMilestoneID) || !validRequiredOptionalID(annotation.PromptSchemaVersion) { + return fmt.Errorf("milestone conclusion candidate %q has invalid conditional fields", annotation.ID) + } + default: + return fmt.Errorf("invalid annotation kind %q", annotation.AnnotationKind) + } if _, exists := runs[annotation.AgentRunID]; !exists { return fmt.Errorf("annotation %q references missing extraction run", annotation.ID) } switch annotation.Status { case CandidatePending, CandidateIgnored, CandidateNotDecision, CandidateStale: - if annotation.ConfirmedDecisionID != nil { - return fmt.Errorf("candidate %q is not confirmed but has a decision", annotation.ID) + if annotation.ConfirmedEntityID != nil { + return fmt.Errorf("candidate %q is not confirmed but has an entity", annotation.ID) } case CandidateConfirmed: - if annotation.ConfirmedDecisionID == nil || !validID(*annotation.ConfirmedDecisionID) { - return fmt.Errorf("confirmed candidate %q has no valid decision", annotation.ID) + if annotation.ConfirmedEntityID == nil || !validID(*annotation.ConfirmedEntityID) { + return fmt.Errorf("confirmed candidate %q has no valid entity", annotation.ID) } default: return fmt.Errorf("invalid annotation status %q", annotation.Status) } dependencies := map[string]bool{} + hasSourceTurn := false for _, dependency := range annotation.Dependencies { - if (dependency.Kind != "observation" && dependency.Kind != "session_view") || !validID(dependency.RevisionID) || !digestRE.MatchString(dependency.Digest) { + if (dependency.Kind != "observation" && dependency.Kind != "session_view" && dependency.Kind != "source_turn") || !validID(dependency.RevisionID) || !digestRE.MatchString(dependency.Digest) { return errors.New("invalid annotation dependency") } + hasSourceTurn = hasSourceTurn || dependency.Kind == "source_turn" key := dependency.Kind + "\x00" + dependency.RevisionID if dependencies[key] { return errors.New("duplicate annotation dependency") } dependencies[key] = true } + if annotation.AnnotationKind == "milestone_conclusion_candidate" && !hasSourceTurn { + return fmt.Errorf("milestone conclusion candidate %q has no source-turn dependency", annotation.ID) + } } return nil } +func validOptionalID(value *string) bool { return value == nil || validID(*value) } +func validRequiredOptionalID(value *string) bool { return value != nil && validID(*value) } + func Parse(data []byte) (StoreRecord, error) { var store StoreRecord if err := strictjson.Decode(data, &store); err != nil { diff --git a/internal/annotation/validate_test.go b/internal/annotation/validate_test.go index de2fd92..d68e5ac 100644 --- a/internal/annotation/validate_test.go +++ b/internal/annotation/validate_test.go @@ -2,6 +2,7 @@ package annotation import ( "os" + "strings" "testing" "github.com/neomei/SessionReviewer/internal/strictjson" @@ -37,12 +38,13 @@ func TestParseRejectsFrozenInvalidFixture(t *testing.T) { func TestValidateAnnotationGraphAndClosedStatuses(t *testing.T) { decisionID := "decision-1" - base := StoreRecord{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", Annotations: []Annotation{{ID: "a", ProjectID: "p", EntityID: "e", Field: "f", Status: "pending", Text: "candidate", GenerationID: "g", SchemaVersion: 1, AnalysisProfile: "profile", AgentRunID: "run", Dependencies: []Dependency{}, Revision: 1, CreatedAt: "now"}}, ExtractionRuns: []Run{{RunID: "run", ProjectID: "p", Status: "completed", ExtractorVersion: "v1", PromptSchemaVersion: "v1", DependencyDigests: []string{}, CreatedAt: "now", UpdatedAt: "now"}}} + entityID, field := "e", "f" + base := StoreRecord{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", Annotations: []Annotation{{ID: "a", ProjectID: "p", AnnotationKind: "decision_candidate", EntityID: &entityID, Field: &field, Status: "pending", Text: "candidate", GenerationID: "g", SchemaVersion: 1, AnalysisProfile: "profile", AgentRunID: "run", Dependencies: []Dependency{}, Revision: 1, CreatedAt: "now", ConfirmedEntityID: nil}}, ExtractionRuns: []Run{{RunID: "run", ProjectID: "p", Status: "completed", ExtractorVersion: "v1", PromptSchemaVersion: "v1", DependencyDigests: []string{}, CreatedAt: "now", UpdatedAt: "now"}}} bad := base bad.Annotations = append([]Annotation(nil), base.Annotations...) - bad.Annotations[0].ConfirmedDecisionID = &decisionID + bad.Annotations[0].ConfirmedEntityID = &decisionID if err := Validate(bad); err == nil { - t.Fatal("accepted confirmed decision on pending candidate") + t.Fatal("accepted confirmed entity on pending candidate") } bad = base bad.Annotations = append([]Annotation(nil), base.Annotations...) @@ -56,3 +58,30 @@ func TestValidateAnnotationGraphAndClosedStatuses(t *testing.T) { t.Fatal("accepted duplicate annotation identity") } } + +func TestMilestoneConclusionAnnotationUsesGenericConfirmationAndNoDecisionFields(t *testing.T) { + confirmed := "milestone-1" + store := StoreRecord{ + SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", + Annotations: []Annotation{{ + ID: "summary-1", ProjectID: "p", AnnotationKind: "milestone_conclusion_candidate", Status: "confirmed", Text: "Bounded conclusion", + GenerationID: "g", SchemaVersion: 1, AnalysisProfile: "profile", AgentRunID: "run", Dependencies: []Dependency{{Kind: "source_turn", RevisionID: "turn-1", Digest: "sha256:" + strings.Repeat("1", 64)}}, + Revision: 1, CreatedAt: "now", ConfirmedEntityID: &confirmed, TargetMilestoneID: stringPointer("milestone-1"), PromptSchemaVersion: stringPointer("summary-v1"), + }}, + ExtractionRuns: []Run{{RunID: "run", ProjectID: "p", Status: "completed", ExtractorVersion: "v1", PromptSchemaVersion: "summary-v1", DependencyDigests: []string{"sha256:" + strings.Repeat("1", 64)}, CreatedAt: "now", UpdatedAt: "now"}}, + } + if err := Validate(store); err != nil { + t.Fatalf("generic milestone annotation rejected: %v", err) + } + store.Annotations[0].EntityID = stringPointer("decision-only") + if err := Validate(store); err == nil { + t.Fatal("accepted decision-only entity field on milestone conclusion") + } + store.Annotations[0].EntityID = nil + store.Annotations[0].Dependencies[0].Kind = "session_view" + if err := Validate(store); err == nil { + t.Fatal("accepted milestone conclusion without source-turn dependency") + } +} + +func stringPointer(value string) *string { return &value } diff --git a/internal/cli/contracts.go b/internal/cli/contracts.go index 84529d5..b5d1b15 100644 --- a/internal/cli/contracts.go +++ b/internal/cli/contracts.go @@ -10,11 +10,12 @@ import ( ) const ( - MaxInspectPageSize = 100 - MaxInspectQueryBytes = 256 - MaxDecisionInputBytes = 64 << 10 - MaxOpaqueCursorBytes = 4096 - MaxInspectResponseBytes = 1 << 20 + MaxInspectPageSize = 100 + MaxInspectQueryBytes = 256 + MaxDecisionInputBytes = 64 << 10 + MaxOpaqueCursorBytes = 4096 + MaxInspectResponseBytes = 1 << 20 + MaxConversationSourceReadBytes = 64 << 10 ) const InspectExecutionTimeout = 5 * time.Second @@ -64,6 +65,43 @@ type InspectRequest struct { Limit int QueryKind string Query string + TurnUnitID string + MessageCursor string +} + +type ConversationSourceCoverage struct { + SourceBytes int + ReturnedBytes int + Truncated bool +} + +type EvolutionRequest struct { + Command string + Subcommand string + ProjectID string + MilestoneID string + Status string + ExpectedGenerationID string + CandidateID string + ExpectedCandidateRevision int + ExpectedReviewSHA256 string + Action string +} + +type ProblemRequest struct { + Command string + Subcommand string + ProjectID string + Status string + CandidateID string + ExpectedCandidateRevision int + ExpectedProblemMapRevision int + ExpectedReviewSHA256 string + Action string + TargetProblemID string + ProblemID string + NewParentID string + ParentID string } type DecisionRequest struct { @@ -225,11 +263,270 @@ func ParseInspectContract(args []string) (InspectRequest, error) { return parseSessionEventsContract(args[1:]) case "session-search": return parseSessionSearchContract(args[1:]) + case "conversation-chain": + return parseConversationChainContract(args[1:]) default: return InspectRequest{}, contractError("unknown inspect subcommand") } } +func parseConversationChainContract(args []string) (InspectRequest, error) { + allowed := map[string]bool{"project-id": true, "provider": true, "session-id": true, "expected-generation-id": true, "turn-unit-id": true, "message-cursor": true, "limit": true, "json": true} + flags, err := parseContractFlags(args, allowed) + if err != nil { + return InspectRequest{}, err + } + if err = requireFlags(flags, "project-id", "provider", "session-id", "expected-generation-id", "limit"); err != nil { + return InspectRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "provider", "session-id", "expected-generation-id"); err != nil { + return InspectRequest{}, err + } + if flags.values["turn-unit-id"] != "" { + if err = requireSafeIDs(flags, "turn-unit-id"); err != nil { + return InspectRequest{}, err + } + } + if flags.values["message-cursor"] != "" { + if flags.values["turn-unit-id"] == "" { + return InspectRequest{}, contractError("message cursor requires a turn unit") + } + if err = requireBoundedUTF8(flags.values["message-cursor"], MaxOpaqueCursorBytes, "message cursor"); err != nil { + return InspectRequest{}, err + } + } + limit, err := requirePositiveInt(flags.values["limit"]) + if err != nil || limit > 64 { + return InspectRequest{}, contractError("limit must be between 1 and 64") + } + return InspectRequest{Command: "conversation-chain", ProjectID: flags.values["project-id"], Provider: flags.values["provider"], SessionID: flags.values["session-id"], ExpectedGenerationID: flags.values["expected-generation-id"], TurnUnitID: flags.values["turn-unit-id"], MessageCursor: flags.values["message-cursor"], Limit: limit}, nil +} + +func ValidateConversationSourceCoverage(coverage ConversationSourceCoverage) error { + if coverage.SourceBytes < 0 || coverage.ReturnedBytes < 0 || coverage.ReturnedBytes > MaxConversationSourceReadBytes || coverage.ReturnedBytes > coverage.SourceBytes || coverage.Truncated != (coverage.ReturnedBytes < coverage.SourceBytes) { + return contractError("conversation source truncation coverage is inconsistent") + } + return nil +} + +func ParseEvolutionContract(args []string) (EvolutionRequest, error) { + if len(args) == 0 { + return EvolutionRequest{}, contractError("evolution command is required") + } + switch args[0] { + case "summary-candidates": + if len(args) < 2 || args[1] != "list" { + return EvolutionRequest{}, contractError("unknown summary-candidates command") + } + flags, err := parseContractFlags(args[2:], map[string]bool{"project-id": true, "milestone-id": true, "status": true, "json": true}) + if err != nil { + return EvolutionRequest{}, err + } + if err = requireFlags(flags, "project-id", "milestone-id"); err != nil { + return EvolutionRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "milestone-id"); err != nil { + return EvolutionRequest{}, err + } + if status := flags.values["status"]; status != "" { + switch status { + case "pending", "confirmed", "ignored", "not_decision", "stale": + default: + return EvolutionRequest{}, contractError("status is invalid") + } + } + return EvolutionRequest{Command: "summary-candidates", Subcommand: "list", ProjectID: flags.values["project-id"], MilestoneID: flags.values["milestone-id"], Status: flags.values["status"]}, nil + case "summarize": + flags, err := parseContractFlags(args[1:], map[string]bool{"project-id": true, "milestone-id": true, "expected-generation-id": true, "json": true}) + if err != nil { + return EvolutionRequest{}, err + } + if err = requireFlags(flags, "project-id", "milestone-id", "expected-generation-id"); err != nil { + return EvolutionRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "milestone-id", "expected-generation-id"); err != nil { + return EvolutionRequest{}, err + } + return EvolutionRequest{Command: "summarize", ProjectID: flags.values["project-id"], MilestoneID: flags.values["milestone-id"], ExpectedGenerationID: flags.values["expected-generation-id"]}, nil + case "summary-candidate": + if len(args) < 2 || args[1] != "transition" { + return EvolutionRequest{}, contractError("unknown summary-candidate command") + } + flags, err := parseContractFlags(args[2:], map[string]bool{"project-id": true, "milestone-id": true, "candidate-id": true, "expected-candidate-revision": true, "expected-review-sha256": true, "action": true, "json": true}) + if err != nil { + return EvolutionRequest{}, err + } + if err = requireFlags(flags, "project-id", "milestone-id", "candidate-id", "expected-candidate-revision", "expected-review-sha256", "action"); err != nil { + return EvolutionRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "milestone-id", "candidate-id"); err != nil { + return EvolutionRequest{}, err + } + revision, err := requirePositiveInt(flags.values["expected-candidate-revision"]) + if err != nil { + return EvolutionRequest{}, err + } + if err = requireBareSHA(flags.values["expected-review-sha256"]); err != nil { + return EvolutionRequest{}, err + } + switch flags.values["action"] { + case "confirm", "ignore", "restore": + default: + return EvolutionRequest{}, contractError("action is invalid") + } + return EvolutionRequest{Command: "summary-candidate", Subcommand: "transition", ProjectID: flags.values["project-id"], MilestoneID: flags.values["milestone-id"], CandidateID: flags.values["candidate-id"], ExpectedCandidateRevision: revision, ExpectedReviewSHA256: flags.values["expected-review-sha256"], Action: flags.values["action"]}, nil + default: + return EvolutionRequest{}, contractError("unknown evolution command") + } +} + +func ParseProblemContract(args []string) (ProblemRequest, error) { + if len(args) == 0 { + return ProblemRequest{}, contractError("problems command is required") + } + switch args[0] { + case "candidates": + if len(args) < 2 || args[1] != "list" { + return ProblemRequest{}, contractError("unknown problems candidates command") + } + flags, err := parseContractFlags(args[2:], map[string]bool{"project-id": true, "status": true, "json": true}) + if err != nil { + return ProblemRequest{}, err + } + if err = requireFlags(flags, "project-id"); err != nil { + return ProblemRequest{}, err + } + if err = requireSafeIDs(flags, "project-id"); err != nil { + return ProblemRequest{}, err + } + if status := flags.values["status"]; status != "" { + switch status { + case "pending", "applied", "merged", "kept_pending", "stale", "dismissed": + default: + return ProblemRequest{}, contractError("status is invalid") + } + } + return ProblemRequest{Command: "candidates", Subcommand: "list", ProjectID: flags.values["project-id"], Status: flags.values["status"]}, nil + case "candidate": + return parseProblemTransition(args[1:]) + case "move": + return parseProblemMove(args[1:]) + case "reorder": + return parseProblemReorder(args[1:]) + default: + return ProblemRequest{}, contractError("unknown problems command") + } +} + +func parseProblemTransition(args []string) (ProblemRequest, error) { + if len(args) == 0 || args[0] != "transition" { + return ProblemRequest{}, contractError("unknown problem candidate command") + } + flags, err := parseContractFlags(args[1:], map[string]bool{"project-id": true, "candidate-id": true, "expected-candidate-revision": true, "expected-problem-map-revision": true, "expected-review-sha256": true, "action": true, "target-problem-id": true, "json": true}) + if err != nil { + return ProblemRequest{}, err + } + if err = requireFlags(flags, "project-id", "candidate-id", "expected-candidate-revision", "expected-problem-map-revision", "expected-review-sha256", "action"); err != nil { + return ProblemRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "candidate-id"); err != nil { + return ProblemRequest{}, err + } + if flags.values["target-problem-id"] != "" { + if err = requireSafeIDs(flags, "target-problem-id"); err != nil { + return ProblemRequest{}, err + } + } + candidateRevision, err := requirePositiveInt(flags.values["expected-candidate-revision"]) + if err != nil { + return ProblemRequest{}, err + } + mapRevision, err := requirePositiveInt(flags.values["expected-problem-map-revision"]) + if err != nil { + return ProblemRequest{}, err + } + if err = requireBareSHA(flags.values["expected-review-sha256"]); err != nil { + return ProblemRequest{}, err + } + target := flags.values["target-problem-id"] + switch flags.values["action"] { + case "apply_child", "apply_sibling", "merge": + if target == "" { + return ProblemRequest{}, contractError("target problem ID is required for apply or merge") + } + case "keep_pending", "dismiss", "restore": + if target != "" { + return ProblemRequest{}, contractError("target problem ID is forbidden for this action") + } + default: + return ProblemRequest{}, contractError("action is invalid") + } + return ProblemRequest{Command: "candidate", Subcommand: "transition", ProjectID: flags.values["project-id"], CandidateID: flags.values["candidate-id"], ExpectedCandidateRevision: candidateRevision, ExpectedProblemMapRevision: mapRevision, ExpectedReviewSHA256: flags.values["expected-review-sha256"], Action: flags.values["action"], TargetProblemID: target}, nil +} + +func parseProblemMove(args []string) (ProblemRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"project-id": true, "problem-id": true, "new-parent-id": true, "expected-problem-map-revision": true, "expected-review-sha256": true, "json": true}) + if err != nil { + return ProblemRequest{}, err + } + if err = requireFlags(flags, "project-id", "problem-id", "new-parent-id", "expected-problem-map-revision", "expected-review-sha256"); err != nil { + return ProblemRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "problem-id", "new-parent-id"); err != nil { + return ProblemRequest{}, err + } + revision, err := requirePositiveInt(flags.values["expected-problem-map-revision"]) + if err != nil { + return ProblemRequest{}, err + } + if err = requireBareSHA(flags.values["expected-review-sha256"]); err != nil { + return ProblemRequest{}, err + } + return ProblemRequest{Command: "move", ProjectID: flags.values["project-id"], ProblemID: flags.values["problem-id"], NewParentID: flags.values["new-parent-id"], ExpectedProblemMapRevision: revision, ExpectedReviewSHA256: flags.values["expected-review-sha256"]}, nil +} + +func parseProblemReorder(args []string) (ProblemRequest, error) { + flags, err := parseContractFlags(args, map[string]bool{"project-id": true, "parent-id": true, "expected-problem-map-revision": true, "expected-review-sha256": true, "json": true}) + if err != nil { + return ProblemRequest{}, err + } + if err = requireFlags(flags, "project-id", "parent-id", "expected-problem-map-revision", "expected-review-sha256"); err != nil { + return ProblemRequest{}, err + } + if err = requireSafeIDs(flags, "project-id", "parent-id"); err != nil { + return ProblemRequest{}, err + } + revision, err := requirePositiveInt(flags.values["expected-problem-map-revision"]) + if err != nil { + return ProblemRequest{}, err + } + if err = requireBareSHA(flags.values["expected-review-sha256"]); err != nil { + return ProblemRequest{}, err + } + return ProblemRequest{Command: "reorder", ProjectID: flags.values["project-id"], ParentID: flags.values["parent-id"], ExpectedProblemMapRevision: revision, ExpectedReviewSHA256: flags.values["expected-review-sha256"]}, nil +} + +func ValidateCompleteSiblingOrder(current, ordered []string) error { + if len(current) != len(ordered) { + return contractError("sibling order must include every direct child exactly once") + } + expected := map[string]bool{} + for _, id := range current { + if !safeContractID(id) || expected[id] { + return contractError("current sibling set is invalid") + } + expected[id] = true + } + seen := map[string]bool{} + for _, id := range ordered { + if !safeContractID(id) || !expected[id] || seen[id] { + return contractError("sibling order contains a missing, duplicate, or foreign child") + } + seen[id] = true + } + return nil +} + func parseSessionSummaryContract(args []string) (InspectRequest, error) { allowed := map[string]bool{"project-id": true, "provider": true, "session-id": true, "expected-generation-id": true, "json": true} flags, err := parseContractFlags(args, allowed) diff --git a/internal/cli/contracts_test.go b/internal/cli/contracts_test.go index 42fbbf4..fa20688 100644 --- a/internal/cli/contracts_test.go +++ b/internal/cli/contracts_test.go @@ -519,6 +519,106 @@ func TestContractParsersEnforceEveryDigestFormat(t *testing.T) { } } +func TestConversationChainContractRequiresTurnForMessageCursorAndCapsSourceReads(t *testing.T) { + args := []string{"conversation-chain", "--project-id", "p", "--provider", "claude", "--session-id", "same", "--expected-generation-id", "g", "--turn-unit-id", "turn-1", "--message-cursor", "opaque", "--limit", "64", "--json"} + request, err := ParseInspectContract(args) + if err != nil { + t.Fatal(err) + } + if request.Provider != "claude" || request.SessionID != "same" || request.TurnUnitID != "turn-1" || request.MessageCursor != "opaque" || request.Limit != 64 { + t.Fatalf("unexpected conversation-chain request: %+v", request) + } + withoutTurn := removeContractFlag(t, args, "--turn-unit-id") + if _, err := ParseInspectContract(withoutTurn); err == nil { + t.Fatal("accepted message cursor without a turn unit") + } + tooLarge := replaceContractFlagValue(t, args, "--limit", "65") + if _, err := ParseInspectContract(tooLarge); err == nil { + t.Fatal("accepted conversation page above 64 items") + } + if MaxConversationSourceReadBytes != 64<<10 { + t.Fatalf("source read ceiling = %d", MaxConversationSourceReadBytes) + } + if err := ValidateConversationSourceCoverage(ConversationSourceCoverage{SourceBytes: MaxConversationSourceReadBytes + 1, ReturnedBytes: MaxConversationSourceReadBytes, Truncated: false}); err == nil { + t.Fatal("accepted silent source clipping without truncation coverage") + } + if err := ValidateConversationSourceCoverage(ConversationSourceCoverage{SourceBytes: MaxConversationSourceReadBytes + 1, ReturnedBytes: MaxConversationSourceReadBytes, Truncated: true}); err != nil { + t.Fatalf("valid explicit truncation coverage rejected: %v", err) + } +} + +func TestEvolutionContractsFreezeListSummarizeAndTransitionGrammar(t *testing.T) { + list, err := ParseEvolutionContract([]string{"summary-candidates", "list", "--project-id", "p", "--milestone-id", "m", "--status", "pending", "--json"}) + if err != nil || list.Command != "summary-candidates" || list.Subcommand != "list" { + t.Fatalf("summary candidate list = %+v err=%v", list, err) + } + summarize, err := ParseEvolutionContract([]string{"summarize", "--project-id", "p", "--milestone-id", "m", "--expected-generation-id", "g", "--json"}) + if err != nil || summarize.Command != "summarize" || summarize.ExpectedGenerationID != "g" { + t.Fatalf("summarize = %+v err=%v", summarize, err) + } + transition, err := ParseEvolutionContract([]string{"summary-candidate", "transition", "--project-id", "p", "--milestone-id", "m", "--candidate-id", "c", "--expected-candidate-revision", "2", "--expected-review-sha256", contractTestSHA, "--action", "confirm", "--json"}) + if err != nil || transition.ExpectedCandidateRevision != 2 || transition.Action != "confirm" { + t.Fatalf("summary transition = %+v err=%v", transition, err) + } + for _, action := range []string{"confirm", "ignore", "restore"} { + args := []string{"summary-candidate", "transition", "--project-id", "p", "--milestone-id", "m", "--candidate-id", "c", "--expected-candidate-revision", "1", "--expected-review-sha256", contractTestSHA, "--action", action, "--json"} + if _, err := ParseEvolutionContract(args); err != nil { + t.Fatalf("action %q rejected: %v", action, err) + } + } +} + +func TestProblemContractsEnforceTargetAndCASGrammar(t *testing.T) { + if _, err := ParseProblemContract([]string{"candidates", "list", "--project-id", "p", "--status", "pending", "--json"}); err != nil { + t.Fatal(err) + } + base := []string{"candidate", "transition", "--project-id", "p", "--candidate-id", "c", "--expected-candidate-revision", "1", "--expected-problem-map-revision", "2", "--expected-review-sha256", contractTestSHA, "--action", "apply_child", "--json"} + if _, err := ParseProblemContract(base); err == nil { + t.Fatal("accepted apply without target problem ID") + } + withTarget := append(append([]string(nil), base[:len(base)-1]...), "--target-problem-id", "target", "--json") + if request, err := ParseProblemContract(withTarget); err != nil || request.TargetProblemID != "target" { + t.Fatalf("targeted apply = %+v err=%v", request, err) + } + for _, action := range []string{"keep_pending", "dismiss"} { + args := replaceContractFlagValue(t, withTarget, "--action", action) + if _, err := ParseProblemContract(args); err == nil { + t.Fatalf("accepted target problem ID for %s", action) + } + } + for _, action := range []string{"apply_child", "apply_sibling", "merge"} { + args := replaceContractFlagValue(t, withTarget, "--action", action) + if _, err := ParseProblemContract(args); err != nil { + t.Fatalf("targeted action %q rejected: %v", action, err) + } + } + for _, action := range []string{"keep_pending", "dismiss", "restore"} { + args := replaceContractFlagValue(t, base, "--action", action) + if _, err := ParseProblemContract(args); err != nil { + t.Fatalf("targetless action %q rejected: %v", action, err) + } + } +} + +func TestProblemMoveAndReorderRequireCompleteSiblingSet(t *testing.T) { + move, err := ParseProblemContract([]string{"move", "--project-id", "p", "--problem-id", "child", "--new-parent-id", "root", "--expected-problem-map-revision", "2", "--expected-review-sha256", contractTestSHA, "--json"}) + if err != nil || move.NewParentID != "root" { + t.Fatalf("move = %+v err=%v", move, err) + } + reorder, err := ParseProblemContract([]string{"reorder", "--project-id", "p", "--parent-id", "root", "--expected-problem-map-revision", "2", "--expected-review-sha256", contractTestSHA, "--json"}) + if err != nil || reorder.ParentID != "root" { + t.Fatalf("reorder = %+v err=%v", reorder, err) + } + for _, ordered := range [][]string{{"a"}, {"a", "a"}, {"a", "foreign", "b"}} { + if err := ValidateCompleteSiblingOrder([]string{"a", "b"}, ordered); err == nil { + t.Fatalf("accepted incomplete or foreign sibling order: %#v", ordered) + } + } + if err := ValidateCompleteSiblingOrder([]string{"a", "b"}, []string{"b", "a"}); err != nil { + t.Fatalf("complete sibling order rejected: %v", err) + } +} + func TestContractParsersRejectForbiddenInputSurfacesAndPositionals(t *testing.T) { tests := []struct { name string diff --git a/internal/conversationchain/codec.go b/internal/conversationchain/codec.go new file mode 100644 index 0000000..a7baffc --- /dev/null +++ b/internal/conversationchain/codec.go @@ -0,0 +1,89 @@ +package conversationchain + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +func Parse(data []byte) (Document, error) { + var document Document + if err := strictjson.Decode(data, &document); err != nil { + return document, err + } + if err := Validate(document); err != nil { + return document, strictjson.NewRejection(strictjson.CodeContractInvalid, err) + } + if !isZeroDigest(document.Digest) && CanonicalDigest(document) != document.Digest { + return document, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("conversation chain digest mismatch")) + } + return document, nil +} + +func Render(document Document) ([]byte, error) { + normalize(&document) + document.Digest = zeroDigest() + if err := Validate(document); err != nil { + return nil, err + } + document.Digest = CanonicalDigest(document) + body, err := strictjson.Encode(document) + if err != nil { + return nil, err + } + parsed, err := Parse(body) + if err != nil { + return nil, fmt.Errorf("rendered conversation chain failed validation: %w", err) + } + if !reflect.DeepEqual(document, parsed) { + return nil, errors.New("rendered conversation chain changed semantic value") + } + return body, nil +} + +func CanonicalDigest(document Document) string { + body := struct { + SchemaVersion int `json:"schema_version"` + MinimumReaderVersion string `json:"minimum_reader_version"` + ProjectID string `json:"project_id"` + Provider string `json:"provider"` + SessionID string `json:"session_id"` + SessionViewDigest string `json:"session_view_digest"` + DependencyDigest string `json:"dependency_digest"` + SegmentationRuleVersion string `json:"segmentation_rule_version"` + Coverage Coverage `json:"coverage"` + TurnUnits []TurnUnit `json:"turn_units"` + }{document.SchemaVersion, document.MinimumReaderVersion, document.ProjectID, document.Provider, document.SessionID, document.SessionViewDigest, document.DependencyDigest, document.SegmentationRuleVersion, document.Coverage, document.TurnUnits} + encoded, err := strictjson.Encode(body) + if err != nil { + return "" + } + digest := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func normalize(document *Document) { + if document.TurnUnits == nil { + document.TurnUnits = []TurnUnit{} + } + for index := range document.TurnUnits { + turn := &document.TurnUnits[index] + if turn.AssistantMessages == nil { + turn.AssistantMessages = []Message{} + } + if turn.Actions == nil { + turn.Actions = []Action{} + } + if turn.Results == nil { + turn.Results = []Result{} + } + } +} + +func zeroDigest() string { return "sha256:" + strings.Repeat("0", 64) } +func isZeroDigest(value string) bool { return value == zeroDigest() } diff --git a/internal/conversationchain/codec_test.go b/internal/conversationchain/codec_test.go new file mode 100644 index 0000000..833a2b7 --- /dev/null +++ b/internal/conversationchain/codec_test.go @@ -0,0 +1,122 @@ +package conversationchain + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +func TestParseFrozenConversationChainFixtures(t *testing.T) { + valid, err := os.ReadFile("../../testdata/contracts/v4/conversation-chain-v1.valid.json") + if err != nil { + t.Fatal(err) + } + if _, err := Parse(valid); err != nil { + t.Fatalf("valid fixture rejected: %v", err) + } + invalid, err := os.ReadFile("../../testdata/contracts/v4/conversation-chain-v1.invalid.json") + if err != nil { + t.Fatal(err) + } + if _, err := Parse(invalid); err == nil { + t.Fatal("hidden message role was accepted") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) + } +} + +func TestRenderConversationChainNormalizesCollectionsAndBindsDigest(t *testing.T) { + document := frozenChain() + document.TurnUnits[0].AssistantMessages = nil + document.TurnUnits[0].Actions = nil + document.TurnUnits[0].Results = nil + rendered, err := Render(document) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(rendered, &raw); err != nil { + t.Fatal(err) + } + turn := raw["turn_units"].([]any)[0].(map[string]any) + for _, key := range []string{"assistant_messages", "actions", "results"} { + if _, ok := turn[key].([]any); !ok { + t.Fatalf("%s did not render as an array: %#v", key, turn[key]) + } + } + parsed, err := Parse(rendered) + if err != nil { + t.Fatal(err) + } + parsed.SessionViewDigest = "sha256:" + strings.Repeat("9", 64) + tampered, err := json.Marshal(parsed) + if err != nil { + t.Fatal(err) + } + if _, err := Parse(tampered); err == nil { + t.Fatal("accepted a chain whose exact session dependency no longer matches its digest") + } +} + +func TestConversationChainRejectsOversizedVisibleExcerptAndUnauthenticatedSource(t *testing.T) { + document := frozenChain() + document.TurnUnits[0].UserMessage.VisibleExcerpt = strings.Repeat("界", 1366) + if err := Validate(document); err == nil { + t.Fatal("accepted visible excerpt above 4,096 UTF-8 bytes") + } + document = frozenChain() + document.TurnUnits[0].UserMessage.SourceRef.SourceHash = "" + if err := Validate(document); err == nil { + t.Fatal("accepted unauthenticated source reference") + } + document = frozenChain() + document.TurnUnits[0].UserMessage.SourceRef.Provider = "codex" + if err := Validate(document); err == nil { + t.Fatal("accepted source reference bound to a different provider") + } +} + +func TestConversationChainExactObjectsRejectRawToolOutput(t *testing.T) { + fixture, err := os.ReadFile("../../testdata/contracts/v4/conversation-chain-v1.valid.json") + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(fixture, &raw); err != nil { + t.Fatal(err) + } + turn := raw["turn_units"].([]any)[0].(map[string]any) + action := turn["actions"].([]any)[0].(map[string]any) + action["raw_tool_output"] = map[string]any{"secret": true} + body, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if _, err := Parse(body); err == nil { + t.Fatal("accepted arbitrary raw tool output") + } else if got := strictjson.CodeOf(err); got != "wire_shape_invalid" { + t.Fatalf("rejection code = %q, want wire_shape_invalid: %v", got, err) + } +} + +func frozenChain() Document { + return Document{ + SchemaVersion: 1, MinimumReaderVersion: "0.4.0", Digest: "sha256:" + strings.Repeat("0", 64), + ProjectID: "project-p", Provider: "claude", SessionID: "session-1", + SessionViewDigest: "sha256:" + strings.Repeat("1", 64), DependencyDigest: "sha256:" + strings.Repeat("2", 64), + SegmentationRuleVersion: "visible-turn-v1", + Coverage: Coverage{SourceMessages: 1, CapturedMessages: 1, TurnUnits: 1, UnansweredUnits: 1}, + TurnUnits: []TurnUnit{{ + TurnUnitID: "turn-1", Ordinal: 1, StartedAt: "2026-09-04T00:00:00Z", EndedAt: nil, + UserMessage: Message{Role: RoleUser, RevisionID: "revision-user-1", SourceRef: frozenSourceRef(), OccurredAt: "2026-09-04T00:00:00Z", VisibleExcerpt: "question", Truncated: false}, + AssistantMessages: []Message{}, Actions: []Action{}, Results: []Result{}, AnswerState: AnswerNone, + }}, + } +} + +func frozenSourceRef() SourceRef { + return SourceRef{Provider: "claude", SessionID: "session-1", SourceIdentity: "source-1", RecordOrdinal: 7, SourceHash: strings.Repeat("3", 64)} +} diff --git a/internal/conversationchain/types.go b/internal/conversationchain/types.go new file mode 100644 index 0000000..7410efc --- /dev/null +++ b/internal/conversationchain/types.go @@ -0,0 +1,83 @@ +package conversationchain + +type Role string + +const ( + RoleUser Role = "user" + RoleAssistant Role = "assistant" +) + +type AnswerState string + +const ( + AnswerNone AnswerState = "no_answer" + AnswerAnswered AnswerState = "answered" + AnswerPartial AnswerState = "partial" +) + +type SourceRef struct { + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + SourceIdentity string `json:"source_identity" required:"true"` + RecordOrdinal uint64 `json:"record_ordinal" required:"true"` + SourceHash string `json:"source_hash" required:"true"` +} + +type Message struct { + Role Role `json:"role" required:"true"` + RevisionID string `json:"revision_id" required:"true"` + SourceRef SourceRef `json:"source_ref" required:"true"` + OccurredAt string `json:"occurred_at" required:"true"` + VisibleExcerpt string `json:"visible_excerpt" required:"true"` + Truncated bool `json:"truncated" required:"true"` +} + +type Action struct { + RevisionID string `json:"revision_id" required:"true"` + SourceRef SourceRef `json:"source_ref" required:"true"` + Kind string `json:"kind" required:"true"` + ToolName *string `json:"tool_name" required:"true" nullable:"true"` + Excerpt string `json:"excerpt" required:"true"` +} + +type Result struct { + RevisionID string `json:"revision_id" required:"true"` + SourceRef SourceRef `json:"source_ref" required:"true"` + Kind string `json:"kind" required:"true"` + VerificationState string `json:"verification_state" required:"true"` + Excerpt string `json:"excerpt" required:"true"` +} + +type TurnUnit struct { + TurnUnitID string `json:"turn_unit_id" required:"true"` + Ordinal uint64 `json:"ordinal" required:"true"` + StartedAt string `json:"started_at" required:"true"` + EndedAt *string `json:"ended_at" required:"true" nullable:"true"` + UserMessage Message `json:"user_message" required:"true"` + AssistantMessages []Message `json:"assistant_messages" required:"true"` + Actions []Action `json:"actions" required:"true"` + Results []Result `json:"results" required:"true"` + AnswerState AnswerState `json:"answer_state" required:"true"` +} + +type Coverage struct { + SourceMessages uint64 `json:"source_messages" required:"true"` + CapturedMessages uint64 `json:"captured_messages" required:"true"` + TurnUnits uint64 `json:"turn_units" required:"true"` + UnansweredUnits uint64 `json:"unanswered_units" required:"true"` + TruncatedMessages uint64 `json:"truncated_messages" required:"true"` +} + +type Document struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + Digest string `json:"digest" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + SessionViewDigest string `json:"session_view_digest" required:"true"` + DependencyDigest string `json:"dependency_digest" required:"true"` + SegmentationRuleVersion string `json:"segmentation_rule_version" required:"true"` + Coverage Coverage `json:"coverage" required:"true"` + TurnUnits []TurnUnit `json:"turn_units" required:"true"` +} diff --git a/internal/conversationchain/validate.go b/internal/conversationchain/validate.go new file mode 100644 index 0000000..4940a8d --- /dev/null +++ b/internal/conversationchain/validate.go @@ -0,0 +1,109 @@ +package conversationchain + +import ( + "errors" + "fmt" + "regexp" + "unicode/utf8" +) + +var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +var shaPattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +func validID(value string) bool { + return utf8.ValidString(value) && len(value) <= 256 && idPattern.MatchString(value) +} +func validText(value string, limit int) bool { + return utf8.ValidString(value) && len([]byte(value)) <= limit +} + +func Validate(document Document) error { + if document.SchemaVersion != 1 || document.MinimumReaderVersion != "0.4.0" || !validID(document.ProjectID) || !validID(document.Provider) || !validID(document.SessionID) || !digestPattern.MatchString(document.Digest) || !digestPattern.MatchString(document.SessionViewDigest) || !digestPattern.MatchString(document.DependencyDigest) || !validID(document.SegmentationRuleVersion) { + return errors.New("invalid conversation chain metadata") + } + if len(document.TurnUnits) > 65536 { + return errors.New("conversation chain exceeds turn limit") + } + var captured, unanswered, truncated uint64 + turnIDs := make(map[string]bool, len(document.TurnUnits)) + for index, turn := range document.TurnUnits { + if !validID(turn.TurnUnitID) || turnIDs[turn.TurnUnitID] || turn.Ordinal != uint64(index+1) || !validTimestamp(turn.StartedAt) || (turn.EndedAt != nil && !validTimestamp(*turn.EndedAt)) { + return fmt.Errorf("invalid turn unit %d", index) + } + turnIDs[turn.TurnUnitID] = true + if len(turn.AssistantMessages) > 65536 || len(turn.Actions) > 65536 || len(turn.Results) > 65536 { + return fmt.Errorf("turn unit %q exceeds item limit", turn.TurnUnitID) + } + if err := validateMessage(document, turn.UserMessage, RoleUser); err != nil { + return fmt.Errorf("turn unit %q user message: %w", turn.TurnUnitID, err) + } + captured++ + if turn.UserMessage.Truncated { + truncated++ + } + for _, message := range turn.AssistantMessages { + if err := validateMessage(document, message, RoleAssistant); err != nil { + return fmt.Errorf("turn unit %q assistant message: %w", turn.TurnUnitID, err) + } + captured++ + if message.Truncated { + truncated++ + } + } + for _, action := range turn.Actions { + if !validID(action.RevisionID) || !validID(action.Kind) || !validText(action.Excerpt, 4096) || (action.ToolName != nil && !validID(*action.ToolName)) { + return fmt.Errorf("turn unit %q has invalid action", turn.TurnUnitID) + } + if err := validateSourceRef(document, action.SourceRef); err != nil { + return err + } + } + for _, result := range turn.Results { + if !validID(result.RevisionID) || !validID(result.Kind) || !validText(result.Excerpt, 4096) { + return fmt.Errorf("turn unit %q has invalid result", turn.TurnUnitID) + } + switch result.VerificationState { + case "unknown", "passed", "failed", "partial": + default: + return fmt.Errorf("turn unit %q has invalid verification state", turn.TurnUnitID) + } + if err := validateSourceRef(document, result.SourceRef); err != nil { + return err + } + } + switch turn.AnswerState { + case AnswerNone: + if len(turn.AssistantMessages) != 0 { + return fmt.Errorf("turn unit %q claims no answer but has assistant messages", turn.TurnUnitID) + } + unanswered++ + case AnswerAnswered, AnswerPartial: + if len(turn.AssistantMessages) == 0 { + return fmt.Errorf("turn unit %q claims an answer without assistant messages", turn.TurnUnitID) + } + default: + return fmt.Errorf("turn unit %q has invalid answer state", turn.TurnUnitID) + } + } + if document.Coverage.TurnUnits != uint64(len(document.TurnUnits)) || document.Coverage.CapturedMessages != captured || document.Coverage.SourceMessages < captured || document.Coverage.UnansweredUnits != unanswered || document.Coverage.TruncatedMessages != truncated { + return errors.New("conversation chain coverage does not reconcile") + } + return nil +} + +func validateMessage(document Document, message Message, expected Role) error { + if message.Role != expected || !validID(message.RevisionID) || !validTimestamp(message.OccurredAt) || !validText(message.VisibleExcerpt, 4096) { + return errors.New("invalid visible message") + } + return validateSourceRef(document, message.SourceRef) +} + +func validateSourceRef(document Document, ref SourceRef) error { + if ref.Provider != document.Provider || ref.SessionID != document.SessionID || !validID(ref.Provider) || !validID(ref.SessionID) || !validID(ref.SourceIdentity) || !shaPattern.MatchString(ref.SourceHash) { + return errors.New("source reference is not authenticated to the conversation identity") + } + return nil +} + +func validTimestamp(value string) bool { return value != "" && validText(value, 128) } diff --git a/internal/memory/api_compat_test.go b/internal/memory/api_compat_test.go index ac92735..d69a870 100644 --- a/internal/memory/api_compat_test.go +++ b/internal/memory/api_compat_test.go @@ -49,7 +49,7 @@ var ( // the one cross-document invariant that JSON Schema cannot express: index // coverage counts must reconcile with the entries array. func TestV4ContractFixtures(t *testing.T) { - names := []string{"review-presentation-v4", "machine-ledger-v4", "session-index-v1", "session-summary-v1", "session-event-page-v1", "agent-annotation-v1", "pricing-snapshot-v1", "pricing-supplement-v1"} + names := []string{"review-presentation-v4", "machine-ledger-v4", "session-index-v1", "session-summary-v1", "session-event-page-v1", "agent-annotation-v1", "pricing-snapshot-v1", "pricing-supplement-v1", "conversation-chain-v1", "problem-map-candidate-v1"} for _, name := range names { t.Run(name, func(t *testing.T) { schema := readContractJSON(t, filepath.Join("..", "..", "schemas", name+".schema.json")) @@ -309,6 +309,32 @@ func validateContractSchema(schema, value any, path string, root any) error { } return validateContractSchema(external, value, path, external) } + if alternatives, ok := s["oneOf"].([]any); ok { + matches := 0 + for _, alternative := range alternatives { + if validateContractSchema(alternative, value, path, root) == nil { + matches++ + } + } + if matches != 1 { + return fmt.Errorf("%s: oneOf matched %d alternatives", path, matches) + } + } + if alternatives, ok := s["anyOf"].([]any); ok { + matched := false + for _, alternative := range alternatives { + if validateContractSchema(alternative, value, path, root) == nil { + matched = true + break + } + } + if !matched { + return fmt.Errorf("%s: anyOf did not match", path) + } + } + if negated, ok := s["not"]; ok && validateContractSchema(negated, value, path, root) == nil { + return fmt.Errorf("%s: forbidden schema matched", path) + } if constValue, ok := s["const"]; ok && !reflect.DeepEqual(constValue, value) { return fmt.Errorf("%s: want const %v", path, constValue) } diff --git a/internal/migrationv4/migrate.go b/internal/migrationv4/migrate.go index 1252928..590d823 100644 --- a/internal/migrationv4/migrate.go +++ b/internal/migrationv4/migrate.go @@ -111,10 +111,11 @@ func migratePresentation(source reviewv2.AcceptedV3, generationID, projectDigest ProjectID: state.Review.ProjectID, GenerationID: generationID, ProjectViewDigest: projectDigest, Revision: state.Review.Revision, CurrentState: reviewv4.CurrentState{Goal: state.Review.Goal, Stage: state.Review.Stage, Status: state.Review.Status, NextAction: state.Review.NextAction, LastVerification: state.Review.LastVerification}, Timeline: []reviewv4.Timeline{}, Decisions: []reviewv4.Decision{}, Risks: []reviewv4.Risk{}, OpenLoops: []reviewv4.OpenLoop{}, + ProblemMapRevision: 0, ProblemRootIDs: []string{}, ProblemNodes: []reviewv4.ProblemNode{}, ChainDependencies: []reviewv4.ChainDependency{}, HumanPatches: migratePatches(state.Machine.HumanPatches), OrphanPatches: migratePatches(state.Machine.OrphanPatches), GeneratedBaselines: migrateBaselines(state.Machine.GeneratedBaselines, generationID), } for _, event := range state.Events { - result.Timeline = append(result.Timeline, reviewv4.Timeline{ID: event.ID, GenerationID: generationID, OccurredAt: event.OccurredAt, Kind: event.Kind, Title: event.Title, Summary: event.Summary, DecisionIDs: append([]string{}, event.DecisionIDs...)}) + result.Timeline = append(result.Timeline, reviewv4.Timeline{ID: event.ID, GenerationID: generationID, OccurredAt: event.OccurredAt, Kind: event.Kind, Title: event.Title, Summary: event.Summary, DecisionIDs: append([]string{}, event.DecisionIDs...), ClosedLoop: reviewv4.NeutralClosedLoop()}) } for _, decision := range state.Review.Decisions { status, err := migrateDecisionStatus(decision.Status) diff --git a/internal/problemmap/candidate_codec.go b/internal/problemmap/candidate_codec.go new file mode 100644 index 0000000..29e4b65 --- /dev/null +++ b/internal/problemmap/candidate_codec.go @@ -0,0 +1,95 @@ +package problemmap + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "reflect" + "strings" + + "github.com/neomei/SessionReviewer/internal/reviewv4" + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +func ParseCandidates(data []byte) (CandidateStore, error) { + var store CandidateStore + if err := strictjson.Decode(data, &store); err != nil { + return store, err + } + if err := ValidateCandidates(store); err != nil { + return store, strictjson.NewRejection(strictjson.CodeContractInvalid, err) + } + if !isZeroDigest(store.Digest) && CanonicalDigest(store) != store.Digest { + return store, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("problem candidate store digest mismatch")) + } + return store, nil +} + +func RenderCandidates(store CandidateStore) ([]byte, error) { + normalizeCandidates(&store) + store.Digest = zeroDigest() + if err := ValidateCandidates(store); err != nil { + return nil, err + } + store.Digest = CanonicalDigest(store) + body, err := strictjson.Encode(store) + if err != nil { + return nil, err + } + parsed, err := ParseCandidates(body) + if err != nil { + return nil, fmt.Errorf("rendered problem candidates failed validation: %w", err) + } + if !reflect.DeepEqual(store, parsed) { + return nil, errors.New("rendered problem candidates changed semantic value") + } + return body, nil +} + +func CanonicalDigest(store CandidateStore) string { + body := struct { + SchemaVersion int `json:"schema_version"` + MinimumReaderVersion string `json:"minimum_reader_version"` + ProjectID string `json:"project_id"` + Candidates []Candidate `json:"candidates"` + }{store.SchemaVersion, store.MinimumReaderVersion, store.ProjectID, store.Candidates} + encoded, err := strictjson.Encode(body) + if err != nil { + return "" + } + digest := sha256.Sum256(encoded) + return "sha256:" + hex.EncodeToString(digest[:]) +} + +func normalizeCandidates(store *CandidateStore) { + if store.Candidates == nil { + store.Candidates = []Candidate{} + } + for index := range store.Candidates { + candidate := &store.Candidates[index] + if candidate.SourceTurnRefs == nil { + candidate.SourceTurnRefs = []reviewv4.SourceTurnRef{} + } + if candidate.AlternateTargetIDs == nil { + candidate.AlternateTargetIDs = []string{} + } + if candidate.RelatedNodeIDs == nil { + candidate.RelatedNodeIDs = []string{} + } + if candidate.Grounds == nil { + candidate.Grounds = []Ground{} + } + if candidate.DependencyDigests == nil { + candidate.DependencyDigests = []string{} + } + for groundIndex := range candidate.Grounds { + if candidate.Grounds[groundIndex].MatchedFactRefs == nil { + candidate.Grounds[groundIndex].MatchedFactRefs = []string{} + } + } + } +} + +func zeroDigest() string { return "sha256:" + strings.Repeat("0", 64) } +func isZeroDigest(value string) bool { return value == zeroDigest() } diff --git a/internal/problemmap/types.go b/internal/problemmap/types.go new file mode 100644 index 0000000..578c4d6 --- /dev/null +++ b/internal/problemmap/types.go @@ -0,0 +1,80 @@ +package problemmap + +import "github.com/neomei/SessionReviewer/internal/reviewv4" + +type Relation string + +const ( + RelationChild Relation = "child" + RelationSibling Relation = "sibling" + RelationMerge Relation = "merge" + RelationKeepPending Relation = "keep_pending" +) + +type Confidence string + +const ( + ConfidenceHigh Confidence = "high" + ConfidenceMedium Confidence = "medium" + ConfidenceLow Confidence = "low" +) + +type CandidateStatus string + +const ( + CandidatePending CandidateStatus = "pending" + CandidateApplied CandidateStatus = "applied" + CandidateMerged CandidateStatus = "merged" + CandidateKeptPending CandidateStatus = "kept_pending" + CandidateStale CandidateStatus = "stale" + CandidateDismissed CandidateStatus = "dismissed" +) + +type AnalysisMode string + +const ( + AnalysisDeterministic AnalysisMode = "deterministic" + AnalysisAgentRequested AnalysisMode = "agent_requested" +) + +type Ground struct { + RuleID string `json:"rule_id" required:"true"` + RuleVersion string `json:"rule_version" required:"true"` + MatchedFactRefs []string `json:"matched_fact_refs" required:"true"` + Explanation string `json:"explanation" required:"true"` +} + +type Candidate struct { + CandidateID string `json:"candidate_id" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Question string `json:"question" required:"true"` + SourceTurnRefs []reviewv4.SourceTurnRef `json:"source_turn_refs" required:"true"` + RecommendedRelation Relation `json:"recommended_relation" required:"true"` + RecommendedTargetID *string `json:"recommended_target_id" required:"true" nullable:"true"` + AlternateTargetIDs []string `json:"alternate_target_ids" required:"true"` + RelatedNodeIDs []string `json:"related_node_ids" required:"true"` + Grounds []Ground `json:"grounds" required:"true"` + Confidence Confidence `json:"confidence" required:"true"` + Status CandidateStatus `json:"status" required:"true"` + DependencyDigests []string `json:"dependency_digests" required:"true"` + AnalysisMode AnalysisMode `json:"analysis_mode" required:"true"` + AgentRunID *string `json:"agent_run_id" required:"true" nullable:"true"` + Revision int `json:"revision" required:"true"` + CreatedAt string `json:"created_at" required:"true"` + UpdatedAt string `json:"updated_at" required:"true"` +} + +type CandidateStore struct { + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + Digest string `json:"digest" required:"true"` + ProjectID string `json:"project_id" required:"true"` + Candidates []Candidate `json:"candidates" required:"true"` +} + +type MovePreview struct { + ProblemID string `json:"problem_id" required:"true"` + OldPath []string `json:"old_path" required:"true"` + NewPath []string `json:"new_path" required:"true"` + AffectedNodeIDs []string `json:"affected_node_ids" required:"true"` +} diff --git a/internal/problemmap/validate.go b/internal/problemmap/validate.go new file mode 100644 index 0000000..ead0a39 --- /dev/null +++ b/internal/problemmap/validate.go @@ -0,0 +1,246 @@ +package problemmap + +import ( + "errors" + "fmt" + "regexp" + "sort" + "unicode/utf8" + + "github.com/neomei/SessionReviewer/internal/reviewv4" +) + +var idPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +func validID(value string) bool { + return utf8.ValidString(value) && len(value) <= 256 && idPattern.MatchString(value) +} +func validText(value string, limit int) bool { + return utf8.ValidString(value) && len([]byte(value)) <= limit +} + +func ValidateCandidates(store CandidateStore) error { + if store.SchemaVersion != 1 || store.MinimumReaderVersion != "0.4.0" || !digestPattern.MatchString(store.Digest) || !validID(store.ProjectID) { + return errors.New("invalid problem candidate store metadata") + } + if len(store.Candidates) > 65536 { + return errors.New("problem candidate store exceeds item limit") + } + seen := make(map[string]bool, len(store.Candidates)) + for index, candidate := range store.Candidates { + if !validID(candidate.CandidateID) || seen[candidate.CandidateID] || candidate.ProjectID != store.ProjectID || candidate.Question == "" || !validText(candidate.Question, 4096) || len(candidate.SourceTurnRefs) == 0 || len(candidate.SourceTurnRefs) > 256 || len(candidate.AlternateTargetIDs) > 2 || len(candidate.RelatedNodeIDs) > 2 || len(candidate.Grounds) > 256 || len(candidate.DependencyDigests) == 0 || len(candidate.DependencyDigests) > 256 || candidate.Revision < 1 || candidate.CreatedAt == "" || !validText(candidate.CreatedAt, 128) || candidate.UpdatedAt == "" || !validText(candidate.UpdatedAt, 128) { + return fmt.Errorf("invalid or duplicate problem candidate %d", index) + } + seen[candidate.CandidateID] = true + if err := validateSourceTurns(candidate.SourceTurnRefs); err != nil { + return err + } + if err := validateTargetIDs(candidate.AlternateTargetIDs, candidate.RecommendedTargetID); err != nil { + return fmt.Errorf("candidate %q alternate targets: %w", candidate.CandidateID, err) + } + if err := validateTargetIDs(candidate.RelatedNodeIDs, nil); err != nil { + return fmt.Errorf("candidate %q related nodes: %w", candidate.CandidateID, err) + } + switch candidate.RecommendedRelation { + case RelationChild, RelationSibling, RelationMerge: + if candidate.RecommendedTargetID == nil || !validID(*candidate.RecommendedTargetID) { + return fmt.Errorf("candidate %q requires a target", candidate.CandidateID) + } + case RelationKeepPending: + if candidate.RecommendedTargetID != nil { + return fmt.Errorf("candidate %q keep-pending relation cannot have a target", candidate.CandidateID) + } + default: + return fmt.Errorf("candidate %q has invalid relation", candidate.CandidateID) + } + switch candidate.Confidence { + case ConfidenceHigh, ConfidenceMedium, ConfidenceLow: + default: + return fmt.Errorf("candidate %q has invalid confidence", candidate.CandidateID) + } + switch candidate.Status { + case CandidatePending, CandidateApplied, CandidateMerged, CandidateKeptPending, CandidateStale, CandidateDismissed: + default: + return fmt.Errorf("candidate %q has invalid status", candidate.CandidateID) + } + switch candidate.AnalysisMode { + case AnalysisDeterministic: + if candidate.AgentRunID != nil { + return fmt.Errorf("deterministic candidate %q cannot reference an Agent run", candidate.CandidateID) + } + case AnalysisAgentRequested: + if candidate.AgentRunID == nil || !validID(*candidate.AgentRunID) { + return fmt.Errorf("Agent-requested candidate %q requires an Agent run", candidate.CandidateID) + } + default: + return fmt.Errorf("candidate %q has invalid analysis mode", candidate.CandidateID) + } + for _, ground := range candidate.Grounds { + if !validID(ground.RuleID) || !validID(ground.RuleVersion) || len(ground.MatchedFactRefs) > 256 || !validText(ground.Explanation, 4096) { + return fmt.Errorf("candidate %q has invalid grounds", candidate.CandidateID) + } + if err := uniqueIDs(ground.MatchedFactRefs); err != nil { + return err + } + } + if err := validateSortedDigests(candidate.DependencyDigests); err != nil { + return fmt.Errorf("candidate %q dependencies: %w", candidate.CandidateID, err) + } + } + return nil +} + +func validateSourceTurns(refs []reviewv4.SourceTurnRef) error { + seen := map[string]bool{} + for _, ref := range refs { + key := ref.Provider + "\x00" + ref.SessionID + "\x00" + ref.TurnUnitID + if !validID(ref.Provider) || !validID(ref.SessionID) || !validID(ref.TurnUnitID) || seen[key] { + return errors.New("invalid or duplicate source turn reference") + } + seen[key] = true + } + return nil +} + +func validateTargetIDs(ids []string, excluded *string) error { + seen := map[string]bool{} + for _, id := range ids { + if !validID(id) || seen[id] || (excluded != nil && id == *excluded) { + return errors.New("invalid, duplicate, or primary target ID") + } + seen[id] = true + } + return nil +} + +func uniqueIDs(ids []string) error { + seen := map[string]bool{} + for _, id := range ids { + if !validID(id) || seen[id] { + return errors.New("invalid or duplicate ID") + } + seen[id] = true + } + return nil +} + +func validateSortedDigests(digests []string) error { + for index, digest := range digests { + if !digestPattern.MatchString(digest) || (index > 0 && digests[index-1] >= digest) { + return errors.New("dependency digests must be unique and canonically sorted") + } + } + return nil +} + +func ValidateGraph(nodes []reviewv4.ProblemNode) error { + return reviewv4.ValidateProblemGraph(nodes) +} + +func PreviewMove(nodes []reviewv4.ProblemNode, problemID, newParentID string) (MovePreview, error) { + if err := ValidateGraph(nodes); err != nil { + return MovePreview{}, err + } + if !validID(problemID) || (newParentID != "root" && !validID(newParentID)) { + return MovePreview{}, errors.New("invalid move identity") + } + byID := make(map[string]reviewv4.ProblemNode, len(nodes)) + for _, node := range nodes { + byID[node.ID] = node + } + node, exists := byID[problemID] + if !exists { + return MovePreview{}, errors.New("problem does not exist") + } + if newParentID != "root" { + if _, exists := byID[newParentID]; !exists { + return MovePreview{}, errors.New("new parent does not exist") + } + if newParentID == problemID { + return MovePreview{}, errors.New("problem cannot parent itself") + } + } + oldPath := problemPath(byID, problemID) + updated := append([]reviewv4.ProblemNode(nil), nodes...) + var nextOrder int + for _, candidate := range nodes { + if candidate.ID == problemID { + continue + } + if sameParent(candidate.PrimaryParentID, newParentID) && candidate.SiblingOrder >= nextOrder { + nextOrder = candidate.SiblingOrder + 1 + } + } + for index := range updated { + if updated[index].ID != problemID { + continue + } + if newParentID == "root" { + updated[index].PrimaryParentID = nil + } else { + parent := newParentID + updated[index].PrimaryParentID = &parent + } + updated[index].SiblingOrder = nextOrder + node = updated[index] + } + if err := ValidateGraph(updated); err != nil { + return MovePreview{}, err + } + updatedByID := make(map[string]reviewv4.ProblemNode, len(updated)) + for _, candidate := range updated { + updatedByID[candidate.ID] = candidate + } + return MovePreview{ProblemID: node.ID, OldPath: oldPath, NewPath: problemPath(updatedByID, problemID), AffectedNodeIDs: subtreeIDs(nodes, problemID)}, nil +} + +func sameParent(parent *string, target string) bool { + if target == "root" { + return parent == nil + } + return parent != nil && *parent == target +} + +func problemPath(nodes map[string]reviewv4.ProblemNode, id string) []string { + path := []string{} + for { + path = append(path, id) + parent := nodes[id].PrimaryParentID + if parent == nil { + break + } + id = *parent + } + for left, right := 0, len(path)-1; left < right; left, right = left+1, right-1 { + path[left], path[right] = path[right], path[left] + } + return path +} + +func subtreeIDs(nodes []reviewv4.ProblemNode, root string) []string { + children := map[string][]reviewv4.ProblemNode{} + for _, node := range nodes { + if node.PrimaryParentID != nil { + children[*node.PrimaryParentID] = append(children[*node.PrimaryParentID], node) + } + } + for parent := range children { + sort.Slice(children[parent], func(i, j int) bool { + if children[parent][i].SiblingOrder != children[parent][j].SiblingOrder { + return children[parent][i].SiblingOrder < children[parent][j].SiblingOrder + } + return children[parent][i].ID < children[parent][j].ID + }) + } + result := []string{} + var visit func(string) + visit = func(id string) { + result = append(result, id) + for _, child := range children[id] { + visit(child.ID) + } + } + visit(root) + return result +} diff --git a/internal/problemmap/validate_test.go b/internal/problemmap/validate_test.go new file mode 100644 index 0000000..1b2e3bd --- /dev/null +++ b/internal/problemmap/validate_test.go @@ -0,0 +1,143 @@ +package problemmap + +import ( + "encoding/json" + "os" + "strings" + "testing" + + "github.com/neomei/SessionReviewer/internal/reviewv4" + "github.com/neomei/SessionReviewer/internal/strictjson" +) + +func TestParseFrozenProblemMapCandidateFixtures(t *testing.T) { + valid, err := os.ReadFile("../../testdata/contracts/v4/problem-map-candidate-v1.valid.json") + if err != nil { + t.Fatal(err) + } + if _, err := ParseCandidates(valid); err != nil { + t.Fatalf("valid fixture rejected: %v", err) + } + invalid, err := os.ReadFile("../../testdata/contracts/v4/problem-map-candidate-v1.invalid.json") + if err != nil { + t.Fatal(err) + } + if _, err := ParseCandidates(invalid); err == nil { + t.Fatal("deterministic candidate with an Agent run was accepted") + } else if got := strictjson.CodeOf(err); got != "wire_contract_invalid" { + t.Fatalf("rejection code = %q, want wire_contract_invalid: %v", got, err) + } +} + +func TestProblemCandidateLimitsAlternatesAndRelatedNodes(t *testing.T) { + store := frozenCandidates() + store.Candidates[0].AlternateTargetIDs = []string{"p-1", "p-2", "p-3"} + if err := ValidateCandidates(store); err == nil { + t.Fatal("accepted more than two alternate targets") + } + store = frozenCandidates() + store.Candidates[0].RelatedNodeIDs = []string{"p-1", "p-2", "p-3"} + if err := ValidateCandidates(store); err == nil { + t.Fatal("accepted more than two related nodes") + } +} + +func TestRenderProblemCandidatesNormalizesCollectionsAndBindsDigest(t *testing.T) { + store := frozenCandidates() + store.Candidates[0].AlternateTargetIDs = nil + store.Candidates[0].RelatedNodeIDs = nil + store.Candidates[0].Grounds[0].MatchedFactRefs = nil + rendered, err := RenderCandidates(store) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(rendered, &raw); err != nil { + t.Fatal(err) + } + candidate := raw["candidates"].([]any)[0].(map[string]any) + for _, key := range []string{"alternate_target_ids", "related_node_ids"} { + if _, ok := candidate[key].([]any); !ok { + t.Fatalf("%s did not render as an array", key) + } + } + parsed, err := ParseCandidates(rendered) + if err != nil { + t.Fatal(err) + } + parsed.Candidates[0].Question = "tampered" + tampered, err := json.Marshal(parsed) + if err != nil { + t.Fatal(err) + } + if _, err := ParseCandidates(tampered); err == nil { + t.Fatal("accepted tampered candidate store digest") + } +} + +func TestProblemGraphRejectsCycle(t *testing.T) { + nodes := []reviewv4.ProblemNode{ + {ID: "p-a", PrimaryParentID: stringPtr("p-b")}, + {ID: "p-b", PrimaryParentID: stringPtr("p-a")}, + } + if err := ValidateGraph(nodes); err == nil { + t.Fatal("accepted problem cycle") + } +} + +func TestProblemGraphRejectsMissingRelationsAndDuplicateSiblingOrder(t *testing.T) { + base := []reviewv4.ProblemNode{problemNode("p-a", nil, 0), problemNode("p-b", stringPtr("p-a"), 0)} + bad := append([]reviewv4.ProblemNode(nil), base...) + bad[1].RelatedNodeIDs = []string{"missing"} + if err := ValidateGraph(bad); err == nil { + t.Fatal("accepted missing related node") + } + bad = append(bad[:0:0], base...) + bad = append(bad, problemNode("p-c", stringPtr("p-a"), 0)) + if err := ValidateGraph(bad); err == nil { + t.Fatal("accepted duplicate sibling order") + } +} + +func TestPreviewMoveRejectsCycleAndReportsAffectedSubtree(t *testing.T) { + nodes := []reviewv4.ProblemNode{ + problemNode("root", nil, 0), + problemNode("child", stringPtr("root"), 0), + problemNode("grandchild", stringPtr("child"), 0), + problemNode("other", nil, 1), + } + if _, err := PreviewMove(nodes, "root", "grandchild"); err == nil { + t.Fatal("accepted a move below its own descendant") + } + preview, err := PreviewMove(nodes, "child", "other") + if err != nil { + t.Fatal(err) + } + if strings.Join(preview.OldPath, "/") != "root/child" || strings.Join(preview.NewPath, "/") != "other/child" || strings.Join(preview.AffectedNodeIDs, ",") != "child,grandchild" { + t.Fatalf("unexpected move preview: %+v", preview) + } +} + +func frozenCandidates() CandidateStore { + return CandidateStore{ + SchemaVersion: 1, MinimumReaderVersion: "0.4.0", Digest: "sha256:" + strings.Repeat("0", 64), ProjectID: "project-p", + Candidates: []Candidate{{ + CandidateID: "candidate-1", ProjectID: "project-p", Question: "Where does this belong?", + SourceTurnRefs: []reviewv4.SourceTurnRef{{Provider: "opencode", SessionID: "session-1", TurnUnitID: "turn-1"}}, + RecommendedRelation: RelationKeepPending, RecommendedTargetID: nil, AlternateTargetIDs: []string{}, RelatedNodeIDs: []string{}, + Grounds: []Ground{{RuleID: "rule-1", RuleVersion: "v1", MatchedFactRefs: []string{"fact-1"}, Explanation: "No stable parent signal."}}, + Confidence: ConfidenceLow, Status: CandidatePending, DependencyDigests: []string{"sha256:" + strings.Repeat("1", 64)}, + AnalysisMode: AnalysisDeterministic, AgentRunID: nil, Revision: 1, CreatedAt: "2026-09-04T00:00:00Z", UpdatedAt: "2026-09-04T00:00:00Z", + }}, + } +} + +func problemNode(id string, parent *string, order int) reviewv4.ProblemNode { + return reviewv4.ProblemNode{ + ID: id, Question: id + "?", PrimaryParentID: parent, RelatedNodeIDs: []string{}, WorkflowState: "not_started", AnswerState: "no_answer", + CompletionCriterion: "", CurrentConclusion: "", SourceTurnRefs: []reviewv4.SourceTurnRef{}, Provenance: "human_created", + FirstProposedAt: "2026-09-04T00:00:00Z", SiblingOrder: order, ConfirmedAt: nil, Revision: 1, + } +} + +func stringPtr(value string) *string { return &value } diff --git a/internal/reviewv4/codec_test.go b/internal/reviewv4/codec_test.go index 83ca760..7208a52 100644 --- a/internal/reviewv4/codec_test.go +++ b/internal/reviewv4/codec_test.go @@ -310,6 +310,72 @@ func TestRenderLedgerPreservesExplicitEmptyOptionalArrays(t *testing.T) { } } +func TestValidatePresentationRequiresHonestClosedLoopConclusion(t *testing.T) { + presentation := minimumPresentation() + presentation.Timeline = []Timeline{{ + ID: "milestone-1", GenerationID: presentation.GenerationID, OccurredAt: "2026-09-04T00:00:00Z", Kind: "milestone", Title: "Milestone", Summary: "Summary", DecisionIDs: []string{}, + ClosedLoop: neutralClosedLoop(), + }} + presentation.Timeline[0].ClosedLoop.Conclusion.Text = "invented answer" + if err := ValidatePresentation(presentation); err == nil { + t.Fatal("accepted missing conclusion with non-empty text") + } + presentation.Timeline[0].ClosedLoop.Conclusion = ClosedLoopConclusion{Kind: ConclusionVisibleAnswerExcerpt, Text: "", MissingReason: nil, SourceTurnRefs: []SourceTurnRef{}} + if err := ValidatePresentation(presentation); err == nil { + t.Fatal("accepted a non-missing conclusion without text") + } +} + +func TestValidatePresentationChecksDeclaredProblemRootsAndSourceTurns(t *testing.T) { + presentation := minimumPresentation() + presentation.ProblemMapRevision = 1 + presentation.ProblemNodes = []ProblemNode{{ + ID: "problem-1", Question: "Why?", PrimaryParentID: nil, RelatedNodeIDs: []string{}, WorkflowState: "not_started", AnswerState: "no_answer", + CompletionCriterion: "", CurrentConclusion: "", SourceTurnRefs: []SourceTurnRef{{Provider: "claude", SessionID: "session-1", TurnUnitID: "turn-1"}}, + Provenance: "human_created", FirstProposedAt: "2026-09-04T00:00:00Z", SiblingOrder: 0, ConfirmedAt: nil, Revision: 1, + }} + presentation.ProblemRootIDs = []string{"problem-1"} + presentation.ChainDependencies = []ChainDependency{{ + Provider: "claude", SessionID: "session-1", SessionViewDigest: "sha256:" + strings.Repeat("1", 64), + DependencyDigest: "sha256:" + strings.Repeat("2", 64), TurnUnitIDs: []string{"turn-1"}, + }} + if err := ValidatePresentation(presentation); err != nil { + t.Fatalf("valid formal problem graph rejected: %v", err) + } + duplicateDependency := presentation + duplicateDependency.ChainDependencies = append(append([]ChainDependency{}, presentation.ChainDependencies...), presentation.ChainDependencies[0]) + duplicateDependency.ChainDependencies[1].DependencyDigest = "sha256:" + strings.Repeat("3", 64) + if err := ValidatePresentation(duplicateDependency); err == nil { + t.Fatal("accepted two chain dependencies for one provider/session identity") + } + presentation.ProblemRootIDs = []string{} + if err := ValidatePresentation(presentation); err == nil { + t.Fatal("accepted root declaration inconsistent with null parents") + } + presentation.ProblemRootIDs = []string{"problem-1"} + presentation.ProblemNodes[0].SourceTurnRefs[0].TurnUnitID = "missing" + if err := ValidatePresentation(presentation); err == nil { + t.Fatal("accepted problem source turn absent from retained chain dependencies") + } +} + +func TestValidatePresentationRequiresCanonicalProblemRootOrder(t *testing.T) { + presentation := minimumPresentation() + presentation.ProblemMapRevision = 1 + presentation.ProblemNodes = []ProblemNode{ + {ID: "problem-later", Question: "Later?", RelatedNodeIDs: []string{}, WorkflowState: "not_started", AnswerState: "no_answer", SourceTurnRefs: []SourceTurnRef{}, Provenance: "human_created", FirstProposedAt: "now", SiblingOrder: 1, Revision: 1}, + {ID: "problem-first", Question: "First?", RelatedNodeIDs: []string{}, WorkflowState: "not_started", AnswerState: "no_answer", SourceTurnRefs: []SourceTurnRef{}, Provenance: "human_created", FirstProposedAt: "now", SiblingOrder: 0, Revision: 1}, + } + presentation.ProblemRootIDs = []string{"problem-later", "problem-first"} + if err := ValidatePresentation(presentation); err == nil { + t.Fatal("accepted problem roots outside sibling order") + } + presentation.ProblemRootIDs = []string{"problem-first", "problem-later"} + if err := ValidatePresentation(presentation); err != nil { + t.Fatalf("canonical problem root order rejected: %v", err) + } +} + func TestValidateLedgerUsesOnlyCurrentPricingForAggregateCompleteness(t *testing.T) { ledger := frozenLedger(t) historical := ledger.PricingSnapshots[0] @@ -364,7 +430,19 @@ func completePricingSnapshot(t *testing.T, id string) pricing.Snapshot { } func minimumPresentation() Presentation { - return Presentation{SchemaVersion: 4, MinimumReaderVersion: "0.4.0", MinimumWriterVersion: "0.4.0", ProjectID: "p", GenerationID: "g", ProjectViewDigest: "sha256:" + strings.Repeat("1", 64), CurrentState: CurrentState{}, Timeline: []Timeline{}, Decisions: []Decision{}, Risks: []Risk{}, OpenLoops: []OpenLoop{}, HumanPatches: []Patch{}, OrphanPatches: []Patch{}, GeneratedBaselines: []Baseline{}} + return Presentation{SchemaVersion: 4, MinimumReaderVersion: "0.4.0", MinimumWriterVersion: "0.4.0", ProjectID: "p", GenerationID: "g", ProjectViewDigest: "sha256:" + strings.Repeat("1", 64), CurrentState: CurrentState{}, Timeline: []Timeline{}, Decisions: []Decision{}, Risks: []Risk{}, OpenLoops: []OpenLoop{}, ProblemMapRevision: 0, ProblemRootIDs: []string{}, ProblemNodes: []ProblemNode{}, ChainDependencies: []ChainDependency{}, HumanPatches: []Patch{}, OrphanPatches: []Patch{}, GeneratedBaselines: []Baseline{}} +} + +func neutralClosedLoop() ClosedLoop { + missing := "not_captured" + return ClosedLoop{ + TriggerQuestion: ClosedLoopSegment{State: "missing", Text: "", MissingReason: &missing, SourceTurnRefs: []SourceTurnRef{}}, + Conclusion: ClosedLoopConclusion{Kind: ConclusionMissing, Text: "", MissingReason: &missing, SourceTurnRefs: []SourceTurnRef{}}, + Execution: ClosedLoopSegment{State: "missing", Text: "", MissingReason: &missing, SourceTurnRefs: []SourceTurnRef{}}, + Verification: ClosedLoopSegment{State: "missing", Text: "", MissingReason: &missing, SourceTurnRefs: []SourceTurnRef{}}, + ImpactAndFollowUp: ClosedLoopSegment{State: "missing", Text: "", MissingReason: &missing, SourceTurnRefs: []SourceTurnRef{}}, + SourceTurnRefs: []SourceTurnRef{}, Coverage: ClosedLoopCoverage{}, + } } func minimumDecision(id string, supersedes []string) Decision { diff --git a/internal/reviewv4/types.go b/internal/reviewv4/types.go index cac378b..f6c12da 100644 --- a/internal/reviewv4/types.go +++ b/internal/reviewv4/types.go @@ -54,13 +54,78 @@ type CurrentState struct { LastVerification string `json:"last_verification" required:"true"` } type Timeline struct { - ID string `json:"id" required:"true"` - GenerationID string `json:"generation_id" required:"true"` - OccurredAt string `json:"occurred_at" required:"true"` - Kind string `json:"kind" required:"true"` - Title string `json:"title" required:"true"` - Summary string `json:"summary" required:"true"` - DecisionIDs []string `json:"decision_ids" required:"true"` + ID string `json:"id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + OccurredAt string `json:"occurred_at" required:"true"` + Kind string `json:"kind" required:"true"` + Title string `json:"title" required:"true"` + Summary string `json:"summary" required:"true"` + DecisionIDs []string `json:"decision_ids" required:"true"` + ClosedLoop ClosedLoop `json:"closed_loop" required:"true"` +} +type SourceTurnRef struct { + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + TurnUnitID string `json:"turn_unit_id" required:"true"` +} +type ClosedLoopSegment struct { + State string `json:"state" required:"true"` + Text string `json:"text" required:"true"` + MissingReason *string `json:"missing_reason" required:"true" nullable:"true"` + SourceTurnRefs []SourceTurnRef `json:"source_turn_refs" required:"true"` +} +type ConclusionKind string + +const ( + ConclusionVisibleAnswerExcerpt ConclusionKind = "visible_answer_excerpt" + ConclusionHumanConfirmed ConclusionKind = "human_confirmed" + ConclusionAICandidateConfirmed ConclusionKind = "ai_candidate_confirmed" + ConclusionMissing ConclusionKind = "missing" +) + +type ClosedLoopConclusion struct { + Kind ConclusionKind `json:"kind" required:"true"` + Text string `json:"text" required:"true"` + MissingReason *string `json:"missing_reason" required:"true" nullable:"true"` + SourceTurnRefs []SourceTurnRef `json:"source_turn_refs" required:"true"` +} +type ClosedLoopCoverage struct { + SourceTurns uint64 `json:"source_turns" required:"true"` + CapturedTurns uint64 `json:"captured_turns" required:"true"` + TruncatedTurns uint64 `json:"truncated_turns" required:"true"` + SourceUnavailableTurns uint64 `json:"source_unavailable_turns" required:"true"` +} +type ClosedLoop struct { + TriggerQuestion ClosedLoopSegment `json:"trigger_question" required:"true"` + Conclusion ClosedLoopConclusion `json:"conclusion" required:"true"` + Execution ClosedLoopSegment `json:"execution" required:"true"` + Verification ClosedLoopSegment `json:"verification" required:"true"` + ImpactAndFollowUp ClosedLoopSegment `json:"impact_and_follow_up" required:"true"` + SourceTurnRefs []SourceTurnRef `json:"source_turn_refs" required:"true"` + Coverage ClosedLoopCoverage `json:"coverage" required:"true"` +} +type ProblemNode struct { + ID string `json:"id" required:"true"` + Question string `json:"question" required:"true"` + PrimaryParentID *string `json:"primary_parent_id" required:"true" nullable:"true"` + RelatedNodeIDs []string `json:"related_node_ids" required:"true"` + WorkflowState string `json:"workflow_state" required:"true"` + AnswerState string `json:"answer_state" required:"true"` + CompletionCriterion string `json:"completion_criterion" required:"true"` + CurrentConclusion string `json:"current_conclusion" required:"true"` + SourceTurnRefs []SourceTurnRef `json:"source_turn_refs" required:"true"` + Provenance string `json:"provenance" required:"true"` + FirstProposedAt string `json:"first_proposed_at" required:"true"` + SiblingOrder int `json:"sibling_order" required:"true"` + ConfirmedAt *string `json:"confirmed_at" required:"true" nullable:"true"` + Revision int `json:"revision" required:"true"` +} +type ChainDependency struct { + Provider string `json:"provider" required:"true"` + SessionID string `json:"session_id" required:"true"` + SessionViewDigest string `json:"session_view_digest" required:"true"` + DependencyDigest string `json:"dependency_digest" required:"true"` + TurnUnitIDs []string `json:"turn_unit_ids" required:"true"` } type SessionRef struct { Provider string `json:"provider" required:"true"` @@ -114,21 +179,25 @@ type Baseline struct { GeneratedHash string `json:"generated_hash" required:"true"` } type Presentation struct { - SchemaVersion int `json:"schema_version" required:"true"` - MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` - MinimumWriterVersion string `json:"minimum_writer_version" required:"true"` - ProjectID string `json:"project_id" required:"true"` - GenerationID string `json:"generation_id" required:"true"` - ProjectViewDigest string `json:"project_view_digest" required:"true"` - Revision int `json:"revision" required:"true"` - CurrentState CurrentState `json:"current_state" required:"true"` - Timeline []Timeline `json:"timeline" required:"true"` - Decisions []Decision `json:"decisions" required:"true"` - Risks []Risk `json:"risks" required:"true"` - OpenLoops []OpenLoop `json:"open_loops" required:"true"` - HumanPatches []Patch `json:"human_patches" required:"true"` - OrphanPatches []Patch `json:"orphan_patches" required:"true"` - GeneratedBaselines []Baseline `json:"generated_baselines" required:"true"` + SchemaVersion int `json:"schema_version" required:"true"` + MinimumReaderVersion string `json:"minimum_reader_version" required:"true"` + MinimumWriterVersion string `json:"minimum_writer_version" required:"true"` + ProjectID string `json:"project_id" required:"true"` + GenerationID string `json:"generation_id" required:"true"` + ProjectViewDigest string `json:"project_view_digest" required:"true"` + Revision int `json:"revision" required:"true"` + CurrentState CurrentState `json:"current_state" required:"true"` + Timeline []Timeline `json:"timeline" required:"true"` + Decisions []Decision `json:"decisions" required:"true"` + Risks []Risk `json:"risks" required:"true"` + OpenLoops []OpenLoop `json:"open_loops" required:"true"` + ProblemMapRevision int `json:"problem_map_revision" required:"true"` + ProblemRootIDs []string `json:"problem_root_ids" required:"true"` + ProblemNodes []ProblemNode `json:"problem_nodes" required:"true"` + ChainDependencies []ChainDependency `json:"chain_dependencies" required:"true"` + HumanPatches []Patch `json:"human_patches" required:"true"` + OrphanPatches []Patch `json:"orphan_patches" required:"true"` + GeneratedBaselines []Baseline `json:"generated_baselines" required:"true"` } type Accounting struct { TotalDurationMS uint64 `json:"total_duration_ms" required:"true"` diff --git a/internal/reviewv4/validate.go b/internal/reviewv4/validate.go index 8b775c0..29c306e 100644 --- a/internal/reviewv4/validate.go +++ b/internal/reviewv4/validate.go @@ -5,6 +5,8 @@ import ( "fmt" "math" "regexp" + "sort" + "strings" "github.com/neomei/SessionReviewer/internal/pricing" ) @@ -41,9 +43,13 @@ func ValidatePresentation(p Presentation) error { return errors.New("current state text exceeds limit") } } - if len(p.Timeline) > 65536 || len(p.Decisions) > 65536 || len(p.Risks) > 65536 || len(p.OpenLoops) > 65536 || len(p.HumanPatches) > 65536 || len(p.OrphanPatches) > 65536 || len(p.GeneratedBaselines) > 65536 { + if len(p.Timeline) > 65536 || len(p.Decisions) > 65536 || len(p.Risks) > 65536 || len(p.OpenLoops) > 65536 || len(p.ProblemRootIDs) > 65536 || len(p.ProblemNodes) > 65536 || len(p.ChainDependencies) > 65536 || len(p.HumanPatches) > 65536 || len(p.OrphanPatches) > 65536 || len(p.GeneratedBaselines) > 65536 { return errors.New("review presentation exceeds array limit") } + chainTurns, err := validateChainDependencies(p.ChainDependencies) + if err != nil { + return err + } timelineIDs := map[string]bool{} for i, timeline := range p.Timeline { if !validID(timeline.ID) || timeline.GenerationID != p.GenerationID || len(timeline.OccurredAt) > 128 || !validID(timeline.Kind) || !text(timeline.Title, 16384) || !text(timeline.Summary, 16384) || len(timeline.DecisionIDs) > 256 || timelineIDs[timeline.ID] { @@ -53,6 +59,9 @@ func ValidatePresentation(p Presentation) error { if err := uniqueIDs(timeline.DecisionIDs); err != nil { return err } + if err := validateClosedLoop(timeline.ClosedLoop, chainTurns); err != nil { + return fmt.Errorf("timeline %q closed loop: %w", timeline.ID, err) + } } decisions := map[string]Decision{} for i, decision := range p.Decisions { @@ -147,6 +156,20 @@ func ValidatePresentation(p Presentation) error { } loopIDs[loop.ID] = true } + if p.ProblemMapRevision < 0 || (len(p.ProblemNodes) > 0 && p.ProblemMapRevision < 1) { + return errors.New("invalid problem map revision") + } + if err := ValidateProblemGraph(p.ProblemNodes); err != nil { + return err + } + if err := validateProblemRoots(p.ProblemNodes, p.ProblemRootIDs); err != nil { + return err + } + for _, node := range p.ProblemNodes { + if err := validateSourceTurnRefs(node.SourceTurnRefs, chainTurns); err != nil { + return fmt.Errorf("problem %q: %w", node.ID, err) + } + } for _, patch := range append(append([]Patch{}, p.HumanPatches...), p.OrphanPatches...) { if err := validatePatch(patch); err != nil { return err @@ -160,6 +183,258 @@ func ValidatePresentation(p Presentation) error { return nil } +func ValidateConclusion(conclusion ClosedLoopConclusion) error { + if len(conclusion.SourceTurnRefs) > 256 || !text(conclusion.Text, 16384) || !validMissingReason(conclusion.MissingReason) { + return errors.New("invalid conclusion") + } + switch conclusion.Kind { + case ConclusionMissing: + if conclusion.Text != "" || conclusion.MissingReason == nil { + return errors.New("missing conclusion must have empty text and a typed reason") + } + case ConclusionVisibleAnswerExcerpt: + if strings.TrimSpace(conclusion.Text) == "" || len([]byte(conclusion.Text)) > 4096 || conclusion.MissingReason != nil { + return errors.New("visible answer conclusion must contain a bounded excerpt") + } + case ConclusionHumanConfirmed, ConclusionAICandidateConfirmed: + if strings.TrimSpace(conclusion.Text) == "" || conclusion.MissingReason != nil { + return errors.New("confirmed conclusion text is required") + } + default: + return errors.New("invalid conclusion kind") + } + return nil +} + +func validateClosedLoop(loop ClosedLoop, chainTurns map[string]bool) error { + if err := validateSegment(loop.TriggerQuestion); err != nil { + return fmt.Errorf("trigger question: %w", err) + } + if err := ValidateConclusion(loop.Conclusion); err != nil { + return err + } + for name, segment := range map[string]ClosedLoopSegment{"execution": loop.Execution, "verification": loop.Verification, "impact and follow-up": loop.ImpactAndFollowUp} { + if err := validateSegment(segment); err != nil { + return fmt.Errorf("%s: %w", name, err) + } + } + if len(loop.SourceTurnRefs) > 256 || loop.Coverage.CapturedTurns != uint64(len(loop.SourceTurnRefs)) || loop.Coverage.SourceTurns < loop.Coverage.CapturedTurns || loop.Coverage.TruncatedTurns+loop.Coverage.SourceUnavailableTurns > loop.Coverage.SourceTurns { + return errors.New("closed-loop coverage does not reconcile") + } + if err := validateSourceTurnRefs(loop.SourceTurnRefs, chainTurns); err != nil { + return err + } + top := map[string]bool{} + for _, ref := range loop.SourceTurnRefs { + top[sourceTurnKey(ref)] = true + } + groups := [][]SourceTurnRef{loop.TriggerQuestion.SourceTurnRefs, loop.Conclusion.SourceTurnRefs, loop.Execution.SourceTurnRefs, loop.Verification.SourceTurnRefs, loop.ImpactAndFollowUp.SourceTurnRefs} + for _, refs := range groups { + if err := validateSourceTurnRefs(refs, chainTurns); err != nil { + return err + } + for _, ref := range refs { + if !top[sourceTurnKey(ref)] { + return errors.New("closed-loop segment references a turn absent from aggregate references") + } + } + } + return nil +} + +func validateSegment(segment ClosedLoopSegment) error { + if len(segment.SourceTurnRefs) > 256 || !text(segment.Text, 16384) || !validMissingReason(segment.MissingReason) { + return errors.New("invalid closed-loop segment") + } + switch segment.State { + case "missing": + if segment.Text != "" || segment.MissingReason == nil { + return errors.New("missing segment must have empty text and a typed reason") + } + case "present", "partial": + if strings.TrimSpace(segment.Text) == "" || segment.MissingReason != nil { + return errors.New("present or partial segment must have text and no missing reason") + } + default: + return errors.New("invalid closed-loop segment state") + } + return nil +} + +func validMissingReason(reason *string) bool { + if reason == nil { + return true + } + switch *reason { + case "not_captured", "no_visible_answer", "no_execution_evidence", "not_verified", "source_unavailable", "partial_coverage": + return true + default: + return false + } +} + +func NeutralClosedLoop() ClosedLoop { + reason := "not_captured" + segment := func() ClosedLoopSegment { + value := reason + return ClosedLoopSegment{State: "missing", Text: "", MissingReason: &value, SourceTurnRefs: []SourceTurnRef{}} + } + conclusionReason := reason + return ClosedLoop{ + TriggerQuestion: segment(), + Conclusion: ClosedLoopConclusion{Kind: ConclusionMissing, Text: "", MissingReason: &conclusionReason, SourceTurnRefs: []SourceTurnRef{}}, + Execution: segment(), Verification: segment(), ImpactAndFollowUp: segment(), + SourceTurnRefs: []SourceTurnRef{}, Coverage: ClosedLoopCoverage{}, + } +} + +func validateChainDependencies(dependencies []ChainDependency) (map[string]bool, error) { + turns := map[string]bool{} + seen := map[string]bool{} + for _, dependency := range dependencies { + key := dependency.Provider + "\x00" + dependency.SessionID + if !validID(dependency.Provider) || !validID(dependency.SessionID) || !digestRE.MatchString(dependency.SessionViewDigest) || !digestRE.MatchString(dependency.DependencyDigest) || len(dependency.TurnUnitIDs) > 65536 || seen[key] { + return nil, errors.New("invalid or duplicate chain dependency") + } + seen[key] = true + local := map[string]bool{} + for _, turnID := range dependency.TurnUnitIDs { + if !validID(turnID) || local[turnID] { + return nil, errors.New("invalid or duplicate chain turn identity") + } + local[turnID] = true + turns[dependency.Provider+"\x00"+dependency.SessionID+"\x00"+turnID] = true + } + } + return turns, nil +} + +func validateSourceTurnRefs(refs []SourceTurnRef, available map[string]bool) error { + seen := map[string]bool{} + for _, ref := range refs { + key := sourceTurnKey(ref) + if !validID(ref.Provider) || !validID(ref.SessionID) || !validID(ref.TurnUnitID) || seen[key] { + return errors.New("invalid or duplicate source turn reference") + } + seen[key] = true + if !available[key] { + return errors.New("source turn reference is absent from retained chain dependencies") + } + } + return nil +} + +func sourceTurnKey(ref SourceTurnRef) string { + return ref.Provider + "\x00" + ref.SessionID + "\x00" + ref.TurnUnitID +} + +func ValidateProblemGraph(nodes []ProblemNode) error { + byID := make(map[string]ProblemNode, len(nodes)) + for _, node := range nodes { + if !validID(node.ID) || node.Question == "" || !text(node.Question, 4096) || node.SiblingOrder < 0 || node.Revision < 1 || !text(node.CompletionCriterion, 16384) || !text(node.CurrentConclusion, 16384) || node.FirstProposedAt == "" || !text(node.FirstProposedAt, 128) || !optionalText(node.ConfirmedAt, 128) || len(node.RelatedNodeIDs) > 2 || len(node.SourceTurnRefs) > 256 { + return fmt.Errorf("invalid problem node %q", node.ID) + } + if _, exists := byID[node.ID]; exists { + return fmt.Errorf("duplicate problem node %q", node.ID) + } + switch node.WorkflowState { + case "not_started", "in_progress", "paused", "resolved": + default: + return fmt.Errorf("invalid problem workflow state %q", node.WorkflowState) + } + switch node.AnswerState { + case "no_answer", "answered_unverified", "execution_verified": + default: + return fmt.Errorf("invalid problem answer state %q", node.AnswerState) + } + switch node.Provenance { + case "human_created", "migrated", "candidate_confirmed": + default: + return fmt.Errorf("invalid problem provenance %q", node.Provenance) + } + byID[node.ID] = node + } + siblingOrders := map[string]map[int]bool{} + for _, node := range nodes { + parentKey := "\x00root" + if node.PrimaryParentID != nil { + if *node.PrimaryParentID == node.ID { + return errors.New("problem cannot parent itself") + } + if _, exists := byID[*node.PrimaryParentID]; !exists { + return fmt.Errorf("problem %q has missing parent %q", node.ID, *node.PrimaryParentID) + } + parentKey = *node.PrimaryParentID + } + if siblingOrders[parentKey] == nil { + siblingOrders[parentKey] = map[int]bool{} + } + if siblingOrders[parentKey][node.SiblingOrder] { + return fmt.Errorf("duplicate sibling order %d under %q", node.SiblingOrder, parentKey) + } + siblingOrders[parentKey][node.SiblingOrder] = true + related := map[string]bool{} + for _, relation := range node.RelatedNodeIDs { + if relation == node.ID || !validID(relation) || related[relation] { + return fmt.Errorf("problem %q has invalid related node", node.ID) + } + if _, exists := byID[relation]; !exists { + return fmt.Errorf("problem %q has missing related node %q", node.ID, relation) + } + related[relation] = true + } + } + state := map[string]uint8{} + var visit func(string) bool + visit = func(id string) bool { + if state[id] == 1 { + return true + } + if state[id] == 2 { + return false + } + state[id] = 1 + if parent := byID[id].PrimaryParentID; parent != nil && visit(*parent) { + return true + } + state[id] = 2 + return false + } + for id := range byID { + if visit(id) { + return errors.New("problem graph contains cycle") + } + } + return nil +} + +func validateProblemRoots(nodes []ProblemNode, declared []string) error { + if err := uniqueIDs(declared); err != nil { + return fmt.Errorf("problem roots: %w", err) + } + actual := make([]ProblemNode, 0, len(declared)) + for _, node := range nodes { + if node.PrimaryParentID == nil { + actual = append(actual, node) + } + } + if len(actual) != len(declared) { + return errors.New("problem root declarations do not match null parents") + } + sort.Slice(actual, func(i, j int) bool { + if actual[i].SiblingOrder != actual[j].SiblingOrder { + return actual[i].SiblingOrder < actual[j].SiblingOrder + } + return actual[i].ID < actual[j].ID + }) + for index, id := range declared { + if actual[index].ID != id { + return errors.New("problem root declarations do not match null parents") + } + } + return nil +} + func uniqueIDs(values []string) error { seen := map[string]bool{} for _, value := range values { diff --git a/obsidian-plugin/src/contracts/review-v4.ts b/obsidian-plugin/src/contracts/review-v4.ts index 4546b77..5b95ca8 100644 --- a/obsidian-plugin/src/contracts/review-v4.ts +++ b/obsidian-plugin/src/contracts/review-v4.ts @@ -1,4 +1,4 @@ -export type ViewKind = "evolution" | "decisions" | "sessions" | "usage"; +export type ViewKind = "evolution" | "problems" | "decisions" | "sessions" | "usage"; export type SessionIdentity = Readonly<{ provider: string; sessionId: string }>; @@ -37,6 +37,67 @@ export interface TimelineEntryV4 { title: string; summary: string; decision_ids: string[]; + closed_loop: ClosedLoopV4; +} + +export interface SourceTurnRefV4 { + provider: string; + session_id: string; + turn_unit_id: string; +} + +export interface ClosedLoopSegmentV4 { + state: "present" | "partial" | "missing"; + text: string; + missing_reason: "not_captured" | "no_visible_answer" | "no_execution_evidence" | "not_verified" | "source_unavailable" | "partial_coverage" | null; + source_turn_refs: SourceTurnRefV4[]; +} + +export interface ClosedLoopConclusionV4 { + kind: "visible_answer_excerpt" | "human_confirmed" | "ai_candidate_confirmed" | "missing"; + text: string; + missing_reason: "not_captured" | "no_visible_answer" | "no_execution_evidence" | "not_verified" | "source_unavailable" | "partial_coverage" | null; + source_turn_refs: SourceTurnRefV4[]; +} + +export interface ClosedLoopV4 { + trigger_question: ClosedLoopSegmentV4; + conclusion: ClosedLoopConclusionV4; + execution: ClosedLoopSegmentV4; + verification: ClosedLoopSegmentV4; + impact_and_follow_up: ClosedLoopSegmentV4; + source_turn_refs: SourceTurnRefV4[]; + coverage: { + source_turns: number; + captured_turns: number; + truncated_turns: number; + source_unavailable_turns: number; + }; +} + +export interface ProblemNodeV4 { + id: string; + question: string; + primary_parent_id: string | null; + related_node_ids: string[]; + workflow_state: string; + answer_state: string; + completion_criterion: string; + current_conclusion: string; + source_turn_refs: SourceTurnRefV4[]; + provenance: string; + first_proposed_at: string; + sibling_order: number; + confirmed_at: string | null; + revision: number; +} + +export interface ChainDependencyV4 { + provider: string; + session_id: string; + session_view_digest: string; + dependency_digest: string; + turn_unit_ids: string[]; } export interface DecisionV4 { @@ -104,6 +165,10 @@ export interface ReviewPresentationV4 { decisions: DecisionV4[]; risks: RiskV4[]; open_loops: OpenLoopV4[]; + problem_map_revision: number; + problem_root_ids: string[]; + problem_nodes: ProblemNodeV4[]; + chain_dependencies: ChainDependencyV4[]; human_patches: HumanPatchV4[]; orphan_patches: HumanPatchV4[]; generated_baselines: GeneratedBaselineV4[]; @@ -390,7 +455,7 @@ export interface SessionEventPageV1 { } export interface AnnotationDependencyV1 { - kind: "observation" | "session_view"; + kind: "observation" | "session_view" | "source_turn"; revision_id: string; digest: string; } @@ -398,8 +463,9 @@ export interface AnnotationDependencyV1 { export interface AgentAnnotationEntryV1 { id: string; project_id: string; - entity_id: string; - field: string; + annotation_kind: "decision_candidate" | "agreement_candidate" | "milestone_conclusion_candidate"; + entity_id?: string; + field?: string; status: CandidateStatus; text: string; generation_id: string; @@ -409,7 +475,9 @@ export interface AgentAnnotationEntryV1 { dependencies: AnnotationDependencyV1[]; revision: number; created_at: string; - confirmed_decision_id: string | null; + confirmed_entity_id: string | null; + target_milestone_id?: string; + prompt_schema_version?: string; } export interface AnnotationExtractionRunV1 { @@ -433,6 +501,91 @@ export interface AgentAnnotationV1 { export type CandidateListV1 = AgentAnnotationV1; +export interface ConversationSourceRefV1 { + provider: string; + session_id: string; + source_identity: string; + record_ordinal: number; + source_hash: string; +} + +export interface ConversationMessageV1 { + role: "user" | "assistant"; + revision_id: string; + source_ref: ConversationSourceRefV1; + occurred_at: string; + visible_excerpt: string; + truncated: boolean; +} + +export interface ConversationChainV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + digest: string; + project_id: string; + provider: string; + session_id: string; + session_view_digest: string; + dependency_digest: string; + segmentation_rule_version: string; + coverage: { + source_messages: number; + captured_messages: number; + turn_units: number; + unanswered_units: number; + truncated_messages: number; + }; + turn_units: Array<{ + turn_unit_id: string; + ordinal: number; + started_at: string; + ended_at: string | null; + user_message: ConversationMessageV1; + assistant_messages: ConversationMessageV1[]; + actions: Array<{ + revision_id: string; + source_ref: ConversationSourceRefV1; + kind: string; + tool_name: string | null; + excerpt: string; + }>; + results: Array<{ + revision_id: string; + source_ref: ConversationSourceRefV1; + kind: string; + verification_state: string; + excerpt: string; + }>; + answer_state: "no_answer" | "answered" | "partial"; + }>; +} + +export interface ProblemMapCandidateV1 { + schema_version: 1; + minimum_reader_version: "0.4.0"; + digest: string; + project_id: string; + candidates: Array<{ + candidate_id: string; + project_id: string; + question: string; + source_turn_refs: SourceTurnRefV4[]; + recommended_relation: "child" | "sibling" | "merge" | "keep_pending"; + recommended_target_id: string | null; + alternate_target_ids: string[]; + related_node_ids: string[]; + grounds: Array<{ rule_id: string; rule_version: string; matched_fact_refs: string[]; explanation: string }>; + confidence: "high" | "medium" | "low"; + status: "pending" | "applied" | "merged" | "kept_pending" | "stale" | "dismissed"; + dependency_digests: string[]; + analysis_mode: "deterministic" | "agent_requested"; + agent_run_id: string | null; + revision: number; + created_at: string; + updated_at: string; + }>; +} + export interface PricingSupplementV1 { schema_version: 1; minimum_reader_version: "0.4.0"; diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index 55ad9d7..0690bcc 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -6,6 +6,11 @@ import type { AnnotationExtractionRunV1, BillableQuantitiesV1, CandidateListV1, + ChainDependencyV4, + ClosedLoopV4, + ConversationChainV1, + ConversationMessageV1, + ConversationSourceRefV1, CoverageV1, DecisionV4, GeneratedBaselineV4, @@ -18,6 +23,8 @@ import type { PricingRatesV1, PricingSnapshotV1, PricingSupplementV1, + ProblemMapCandidateV1, + ProblemNodeV4, ReviewPresentationV4, SessionEventItemV1, SessionEventPageV1, @@ -32,6 +39,7 @@ import type { SessionSummaryErrorEntryV1, SessionSummaryRulesV1, SessionSummaryV1, + SourceTurnRefV4, TimelineEntryV4 } from "../contracts/review-v4"; @@ -88,6 +96,7 @@ function parseReviewPresentationDocument(source: string): ReviewPresentationV4 { exact(row, "$", [ "schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "revision", "current_state", "timeline", "decisions", "risks", "open_loops", + "problem_map_revision", "problem_root_ids", "problem_nodes", "chain_dependencies", "human_patches", "orphan_patches", "generated_baselines" ]); constant(row.schema_version, 4, "$.schema_version"); @@ -148,6 +157,25 @@ function parseReviewPresentationDocument(source: string): ReviewPresentationV4 { parseUniqueEntityArray(row.open_loops, "$.open_loops", 65536, ["id", "title", "status", "question", "next_experiment", "completion_criterion"], ["title", "status", "question", "next_experiment", "completion_criterion"]); + integer(row.problem_map_revision, "$.problem_map_revision"); + const rootIDs = idArray(row.problem_root_ids, "$.problem_root_ids", 65536, true); + const nodes = boundedArray(row.problem_nodes, "$.problem_nodes", 65536) + .map((node, index) => parseProblemNode(node, `$.problem_nodes[${index}]`)); + assertProblemGraphCore(nodes, rootIDs); + const dependencies = boundedArray(row.chain_dependencies, "$.chain_dependencies", 65536) + .map((dependency, index) => parseChainDependency(dependency, `$.chain_dependencies[${index}]`)); + const sourceTurns = new Set(); + const sessions = new Set(); + for (const dependency of dependencies) { + addUnique(sessions, identityKey(dependency.provider, dependency.session_id), "chain dependency identity"); + for (const turnID of dependency.turn_unit_ids) { + addUnique(sourceTurns, sourceTurnKey(dependency.provider, dependency.session_id, turnID), "chain source turn"); + } + } + for (const node of nodes) assertSourceTurns(node.source_turn_refs, sourceTurns, `problem ${node.id}`); + for (const item of timeline as TimelineEntryV4[]) { + assertClosedLoopSourceTurns(item.closed_loop, sourceTurns, `timeline ${item.id}`); + } parsePatchArray(row.human_patches, "$.human_patches"); parsePatchArray(row.orphan_patches, "$.orphan_patches"); parseBaselineArray(row.generated_baselines, "$.generated_baselines"); @@ -318,6 +346,136 @@ export function parseSessionEventPageV1(source: string): SessionEventPageV1 { return atWireBoundary(() => parseSessionEventPageDocument(source)); } +export function parseConversationChainV1(source: string): ConversationChainV1 { + return atWireBoundary(() => { + const row = documentObject(source, "conversation chain"); + exact(row, "$", [ + "schema_version", "minimum_reader_version", "digest", "project_id", "provider", "session_id", + "session_view_digest", "dependency_digest", "segmentation_rule_version", "coverage", "turn_units" + ]); + constant(row.schema_version, 1, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + const claimedDigest = digest(row.digest, "$.digest"); + id(row.project_id, "$.project_id"); + const provider = id(row.provider, "$.provider"); + const sessionID = id(row.session_id, "$.session_id"); + digest(row.session_view_digest, "$.session_view_digest"); + digest(row.dependency_digest, "$.dependency_digest"); + id(row.segmentation_rule_version, "$.segmentation_rule_version"); + const coverage = object(row.coverage, "$.coverage"); + const coverageKeys = ["source_messages", "captured_messages", "turn_units", "unanswered_units", "truncated_messages"] as const; + exact(coverage, "$.coverage", coverageKeys); + for (const key of coverageKeys) integer(coverage[key], `$.coverage.${key}`); + const turns = boundedArray(row.turn_units, "$.turn_units", 65536); + const turnIDs = new Set(); + let captured = 0; + let unanswered = 0; + let truncated = 0; + for (let index = 0; index < turns.length; index += 1) { + const path = `$.turn_units[${index}]`; + const turn = object(turns[index], path); + exact(turn, path, ["turn_unit_id", "ordinal", "started_at", "ended_at", "user_message", "assistant_messages", "actions", "results", "answer_state"]); + addUnique(turnIDs, id(turn.turn_unit_id, `${path}.turn_unit_id`), "turn unit"); + if (positiveInteger(turn.ordinal, `${path}.ordinal`) !== index + 1) throw new Error(`${path}.ordinal is not canonical`); + text(turn.started_at, `${path}.started_at`, 128, true); + nullableText(turn.ended_at, `${path}.ended_at`, 128); + const user = parseConversationMessage(turn.user_message, `${path}.user_message`, "user", provider, sessionID); + captured += 1; + truncated += user.truncated ? 1 : 0; + const assistants = boundedArray(turn.assistant_messages, `${path}.assistant_messages`, 65536); + for (let item = 0; item < assistants.length; item += 1) { + const message = parseConversationMessage(assistants[item], `${path}.assistant_messages[${item}]`, "assistant", provider, sessionID); + captured += 1; + truncated += message.truncated ? 1 : 0; + } + const actions = boundedArray(turn.actions, `${path}.actions`, 65536); + for (let item = 0; item < actions.length; item += 1) parseConversationAction(actions[item], `${path}.actions[${item}]`, provider, sessionID); + const results = boundedArray(turn.results, `${path}.results`, 65536); + for (let item = 0; item < results.length; item += 1) parseConversationResult(results[item], `${path}.results[${item}]`, provider, sessionID); + const answerState = oneOf(turn.answer_state, `${path}.answer_state`, ["no_answer", "answered", "partial"]); + if (answerState === "no_answer") { + if (assistants.length !== 0) throw new Error(`${path} claims no answer but has assistant messages`); + unanswered += 1; + } else if (assistants.length === 0) throw new Error(`${path} claims an answer without assistant messages`); + } + if (coverage.turn_units !== turns.length || coverage.captured_messages !== captured || + (coverage.source_messages as number) < captured || coverage.unanswered_units !== unanswered || coverage.truncated_messages !== truncated) { + throw new Error("conversation chain coverage does not reconcile"); + } + const result = row as unknown as ConversationChainV1; + if (claimedDigest !== ZERO_DIGEST && canonicalConversationChainDigest(result) !== claimedDigest) { + throw new Error("conversation chain digest mismatch"); + } + return result; + }); +} + +export function parseProblemMapCandidateV1(source: string): ProblemMapCandidateV1 { + return atWireBoundary(() => { + const row = documentObject(source, "problem map candidate store"); + exact(row, "$", ["schema_version", "minimum_reader_version", "digest", "project_id", "candidates"]); + constant(row.schema_version, 1, "$.schema_version"); + version(row.minimum_reader_version, "$.minimum_reader_version"); + const claimedDigest = digest(row.digest, "$.digest"); + const projectID = id(row.project_id, "$.project_id"); + const candidates = boundedArray(row.candidates, "$.candidates", 65536); + const seen = new Set(); + for (let index = 0; index < candidates.length; index += 1) { + const path = `$.candidates[${index}]`; + const candidate = object(candidates[index], path); + exact(candidate, path, [ + "candidate_id", "project_id", "question", "source_turn_refs", "recommended_relation", "recommended_target_id", + "alternate_target_ids", "related_node_ids", "grounds", "confidence", "status", "dependency_digests", + "analysis_mode", "agent_run_id", "revision", "created_at", "updated_at" + ]); + addUnique(seen, id(candidate.candidate_id, `${path}.candidate_id`), "problem candidate"); + if (id(candidate.project_id, `${path}.project_id`) !== projectID) throw new Error(`${path}.project_id does not match store`); + text(candidate.question, `${path}.question`, 4096, true); + const refs = boundedArray(candidate.source_turn_refs, `${path}.source_turn_refs`, 256); + if (refs.length === 0) throw new Error(`${path}.source_turn_refs must not be empty`); + parseSourceTurnRefs(refs, `${path}.source_turn_refs`); + const relation = oneOf(candidate.recommended_relation, `${path}.recommended_relation`, ["child", "sibling", "merge", "keep_pending"]); + const target = nullableID(candidate.recommended_target_id, `${path}.recommended_target_id`); + if (relation === "keep_pending" ? target !== null : target === null) throw new Error(`${path} has an invalid target for its relation`); + const alternates = idArray(candidate.alternate_target_ids, `${path}.alternate_target_ids`, 2, true); + const related = idArray(candidate.related_node_ids, `${path}.related_node_ids`, 2, true); + if (target !== null && alternates.includes(target)) throw new Error(`${path} alternate target repeats primary target`); + void related; + const grounds = boundedArray(candidate.grounds, `${path}.grounds`, 256); + for (let groundIndex = 0; groundIndex < grounds.length; groundIndex += 1) { + const groundPath = `${path}.grounds[${groundIndex}]`; + const ground = object(grounds[groundIndex], groundPath); + exact(ground, groundPath, ["rule_id", "rule_version", "matched_fact_refs", "explanation"]); + id(ground.rule_id, `${groundPath}.rule_id`); + id(ground.rule_version, `${groundPath}.rule_version`); + idArray(ground.matched_fact_refs, `${groundPath}.matched_fact_refs`, 256, true); + text(ground.explanation, `${groundPath}.explanation`, 4096); + } + oneOf(candidate.confidence, `${path}.confidence`, ["high", "medium", "low"]); + oneOf(candidate.status, `${path}.status`, ["pending", "applied", "merged", "kept_pending", "stale", "dismissed"]); + const dependencies = boundedArray(candidate.dependency_digests, `${path}.dependency_digests`, 256); + if (dependencies.length === 0) throw new Error(`${path}.dependency_digests must not be empty`); + let previous = ""; + for (let item = 0; item < dependencies.length; item += 1) { + const current = digest(dependencies[item], `${path}.dependency_digests[${item}]`); + if (previous !== "" && compareGoStrings(previous, current) >= 0) throw new Error(`${path}.dependency_digests must be unique and sorted`); + previous = current; + } + const mode = oneOf(candidate.analysis_mode, `${path}.analysis_mode`, ["deterministic", "agent_requested"]); + const runID = nullableID(candidate.agent_run_id, `${path}.agent_run_id`); + if (mode === "deterministic" ? runID !== null : runID === null) throw new Error(`${path} has invalid Agent run provenance`); + positiveInteger(candidate.revision, `${path}.revision`); + text(candidate.created_at, `${path}.created_at`, 128, true); + text(candidate.updated_at, `${path}.updated_at`, 128, true); + } + const result = row as unknown as ProblemMapCandidateV1; + if (claimedDigest !== ZERO_DIGEST && canonicalProblemMapCandidateDigest(result) !== claimedDigest) { + throw new Error("problem map candidate digest mismatch"); + } + return result; + }); +} + function parseSessionEventPageDocument(source: string): SessionEventPageV1 { const row = documentObject(source, "session event page"); exact(row, "$", [ @@ -431,7 +589,7 @@ export function assertSnapshotBindings(ledger: MachineLedgerV4, index: SessionIn function parseTimeline(value: unknown, path: string, generationID: string): TimelineEntryV4 { const row = object(value, path); - exact(row, path, ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids"]); + exact(row, path, ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids", "closed_loop"]); const parsedGeneration = id(row.generation_id, `${path}.generation_id`); if (parsedGeneration !== generationID) throw new Error(`${path}.generation_id does not match presentation`); id(row.id, `${path}.id`); @@ -440,6 +598,7 @@ function parseTimeline(value: unknown, path: string, generationID: string): Time text(row.title, `${path}.title`, 16384); text(row.summary, `${path}.summary`, 16384); idArray(row.decision_ids, `${path}.decision_ids`, 256, true); + parseClosedLoop(row.closed_loop, `${path}.closed_loop`); return row as unknown as TimelineEntryV4; } @@ -479,6 +638,174 @@ function parseSessionReference(value: unknown, path: string): SessionReferenceV4 return row as unknown as SessionReferenceV4; } +function parseSourceTurnRef(value: unknown, path: string): SourceTurnRefV4 { + const row = object(value, path); + exact(row, path, ["provider", "session_id", "turn_unit_id"]); + id(row.provider, `${path}.provider`); + id(row.session_id, `${path}.session_id`); + id(row.turn_unit_id, `${path}.turn_unit_id`); + return row as unknown as SourceTurnRefV4; +} + +function parseSourceTurnRefs(values: readonly unknown[], path: string): SourceTurnRefV4[] { + const seen = new Set(); + return values.map((value, index) => { + const ref = parseSourceTurnRef(value, `${path}[${index}]`); + addUnique(seen, sourceTurnKey(ref.provider, ref.session_id, ref.turn_unit_id), "source turn reference"); + return ref; + }); +} + +function parseClosedLoop(value: unknown, path: string): ClosedLoopV4 { + const row = object(value, path); + exact(row, path, ["trigger_question", "conclusion", "execution", "verification", "impact_and_follow_up", "source_turn_refs", "coverage"]); + for (const key of ["trigger_question", "execution", "verification", "impact_and_follow_up"] as const) { + parseClosedLoopSegment(row[key], `${path}.${key}`); + } + parseClosedLoopConclusion(row.conclusion, `${path}.conclusion`); + const aggregate = parseSourceTurnRefs(boundedArray(row.source_turn_refs, `${path}.source_turn_refs`, 256), `${path}.source_turn_refs`); + const aggregateSet = new Set(aggregate.map((ref) => sourceTurnKey(ref.provider, ref.session_id, ref.turn_unit_id))); + for (const key of ["trigger_question", "conclusion", "execution", "verification", "impact_and_follow_up"] as const) { + const part = row[key] as JsonObject; + for (const ref of part.source_turn_refs as SourceTurnRefV4[]) { + if (!aggregateSet.has(sourceTurnKey(ref.provider, ref.session_id, ref.turn_unit_id))) { + throw new Error(`${path}.${key} references a turn absent from aggregate references`); + } + } + } + const coverage = object(row.coverage, `${path}.coverage`); + const keys = ["source_turns", "captured_turns", "truncated_turns", "source_unavailable_turns"] as const; + exact(coverage, `${path}.coverage`, keys); + for (const key of keys) integer(coverage[key], `${path}.coverage.${key}`); + if (coverage.captured_turns !== aggregate.length || (coverage.source_turns as number) < aggregate.length || + checkedSum(`${path}.coverage`, coverage.truncated_turns as number, coverage.source_unavailable_turns as number) > (coverage.source_turns as number)) { + throw new Error(`${path}.coverage does not reconcile`); + } + return row as unknown as ClosedLoopV4; +} + +function parseClosedLoopSegment(value: unknown, path: string): void { + const row = object(value, path); + exact(row, path, ["state", "text", "missing_reason", "source_turn_refs"]); + const state = oneOf(row.state, `${path}.state`, ["present", "partial", "missing"]); + const body = text(row.text, `${path}.text`, 16384); + const reason = parseMissingReason(row.missing_reason, `${path}.missing_reason`); + parseSourceTurnRefs(boundedArray(row.source_turn_refs, `${path}.source_turn_refs`, 256), `${path}.source_turn_refs`); + if (state === "missing" ? body !== "" || reason === null : body.trim() === "" || reason !== null) { + throw new Error(`${path} has invalid missing/text semantics`); + } +} + +function parseClosedLoopConclusion(value: unknown, path: string): void { + const row = object(value, path); + exact(row, path, ["kind", "text", "missing_reason", "source_turn_refs"]); + const kind = oneOf(row.kind, `${path}.kind`, ["visible_answer_excerpt", "human_confirmed", "ai_candidate_confirmed", "missing"]); + const body = text(row.text, `${path}.text`, 16384); + const reason = parseMissingReason(row.missing_reason, `${path}.missing_reason`); + parseSourceTurnRefs(boundedArray(row.source_turn_refs, `${path}.source_turn_refs`, 256), `${path}.source_turn_refs`); + if (kind === "missing") { + if (body !== "" || reason === null) throw new Error(`${path} missing conclusion must have empty text and a typed reason`); + } else if (body.trim() === "" || reason !== null) throw new Error(`${path} confirmed conclusion requires text and no missing reason`); + if (kind === "visible_answer_excerpt" && Buffer.byteLength(body, "utf8") > 4096) throw new Error(`${path}.text exceeds 4096 UTF-8 bytes`); +} + +function parseMissingReason(value: unknown, path: string): string | null { + if (value === null) return null; + return oneOf(value, path, ["not_captured", "no_visible_answer", "no_execution_evidence", "not_verified", "source_unavailable", "partial_coverage"]); +} + +function parseProblemNode(value: unknown, path: string): ProblemNodeV4 { + const row = object(value, path); + exact(row, path, [ + "id", "question", "primary_parent_id", "related_node_ids", "workflow_state", "answer_state", + "completion_criterion", "current_conclusion", "source_turn_refs", "provenance", "first_proposed_at", + "sibling_order", "confirmed_at", "revision" + ]); + id(row.id, `${path}.id`); + text(row.question, `${path}.question`, 4096, true); + nullableID(row.primary_parent_id, `${path}.primary_parent_id`); + idArray(row.related_node_ids, `${path}.related_node_ids`, 2, true); + oneOf(row.workflow_state, `${path}.workflow_state`, ["not_started", "in_progress", "paused", "resolved"]); + oneOf(row.answer_state, `${path}.answer_state`, ["no_answer", "answered_unverified", "execution_verified"]); + text(row.completion_criterion, `${path}.completion_criterion`, 16384); + text(row.current_conclusion, `${path}.current_conclusion`, 16384); + parseSourceTurnRefs(boundedArray(row.source_turn_refs, `${path}.source_turn_refs`, 256), `${path}.source_turn_refs`); + oneOf(row.provenance, `${path}.provenance`, ["human_created", "migrated", "candidate_confirmed"]); + text(row.first_proposed_at, `${path}.first_proposed_at`, 128, true); + integer(row.sibling_order, `${path}.sibling_order`); + nullableText(row.confirmed_at, `${path}.confirmed_at`, 128); + positiveInteger(row.revision, `${path}.revision`); + return row as unknown as ProblemNodeV4; +} + +function parseChainDependency(value: unknown, path: string): ChainDependencyV4 { + const row = object(value, path); + exact(row, path, ["provider", "session_id", "session_view_digest", "dependency_digest", "turn_unit_ids"]); + id(row.provider, `${path}.provider`); + id(row.session_id, `${path}.session_id`); + digest(row.session_view_digest, `${path}.session_view_digest`); + digest(row.dependency_digest, `${path}.dependency_digest`); + idArray(row.turn_unit_ids, `${path}.turn_unit_ids`, 65536, true); + return row as unknown as ChainDependencyV4; +} + +export function assertProblemGraph(nodes: readonly ProblemNodeV4[]): void { + const parsed = nodes.map((node, index) => parseProblemNode(node, `$[${index}]`)); + assertProblemGraphCore(parsed); +} + +function assertProblemGraphCore(nodes: readonly ProblemNodeV4[], declaredRoots?: readonly string[]): void { + const byID = new Map(); + for (const node of nodes) { + if (byID.has(node.id)) throw new Error(`duplicate problem node "${node.id}"`); + byID.set(node.id, node); + } + const siblingOrders = new Map>(); + for (const node of nodes) { + const parentKey = node.primary_parent_id ?? "\u0000root"; + if (node.primary_parent_id !== null && !byID.has(node.primary_parent_id)) throw new Error(`problem "${node.id}" has missing parent`); + if (node.primary_parent_id === node.id) throw new Error("problem cannot parent itself"); + const orders = siblingOrders.get(parentKey) ?? new Set(); + if (orders.has(node.sibling_order)) throw new Error(`duplicate sibling order beneath "${parentKey}"`); + orders.add(node.sibling_order); + siblingOrders.set(parentKey, orders); + for (const related of node.related_node_ids) { + if (related === node.id || !byID.has(related)) throw new Error(`problem "${node.id}" has missing or self related relation`); + } + } + const state = new Map(); + const visit = (nodeID: string): void => { + if (state.get(nodeID) === 1) throw new Error("problem graph contains cycle"); + if (state.get(nodeID) === 2) return; + state.set(nodeID, 1); + const parent = byID.get(nodeID)?.primary_parent_id; + if (parent !== null && parent !== undefined) visit(parent); + state.set(nodeID, 2); + }; + for (const id of byID.keys()) visit(id); + if (declaredRoots !== undefined) { + const actual = nodes.filter((node) => node.primary_parent_id === null) + .sort((left, right) => left.sibling_order - right.sibling_order || compareGoStrings(left.id, right.id)) + .map((node) => node.id); + if (declaredRoots.length !== actual.length || declaredRoots.some((id, index) => id !== actual[index])) { + throw new Error("declared problem roots do not match graph roots"); + } + } +} + +function assertSourceTurns(refs: readonly SourceTurnRefV4[], available: ReadonlySet, kind: string): void { + for (const ref of refs) { + if (!available.has(sourceTurnKey(ref.provider, ref.session_id, ref.turn_unit_id))) throw new Error(`${kind} references a missing source turn`); + } +} + +function assertClosedLoopSourceTurns(loop: ClosedLoopV4, available: ReadonlySet, kind: string): void { + assertSourceTurns(loop.source_turn_refs, available, kind); + for (const part of [loop.trigger_question, loop.conclusion, loop.execution, loop.verification, loop.impact_and_follow_up]) { + assertSourceTurns(part.source_turn_refs, available, kind); + } +} + function parseUniqueEntityArray( value: unknown, path: string, @@ -643,6 +970,61 @@ function parseFactCounts(value: unknown, path: string): SessionFactCountsV1 { return row as unknown as SessionFactCountsV1; } +function parseConversationMessage( + value: unknown, + path: string, + expectedRole: "user" | "assistant", + provider: string, + sessionID: string +): ConversationMessageV1 { + const row = object(value, path); + exact(row, path, ["role", "revision_id", "source_ref", "occurred_at", "visible_excerpt", "truncated"]); + if (oneOf(row.role, `${path}.role`, ["user", "assistant"]) !== expectedRole) throw new Error(`${path}.role is not ${expectedRole}`); + id(row.revision_id, `${path}.revision_id`); + parseConversationSourceRef(row.source_ref, `${path}.source_ref`, provider, sessionID); + text(row.occurred_at, `${path}.occurred_at`, 128, true); + text(row.visible_excerpt, `${path}.visible_excerpt`, 4096); + boolean(row.truncated, `${path}.truncated`); + return row as unknown as ConversationMessageV1; +} + +function parseConversationSourceRef( + value: unknown, + path: string, + provider: string, + sessionID: string +): ConversationSourceRefV1 { + const row = object(value, path); + exact(row, path, ["provider", "session_id", "source_identity", "record_ordinal", "source_hash"]); + if (id(row.provider, `${path}.provider`) !== provider || id(row.session_id, `${path}.session_id`) !== sessionID) { + throw new Error(`${path} is not authenticated to the conversation identity`); + } + id(row.source_identity, `${path}.source_identity`); + integer(row.record_ordinal, `${path}.record_ordinal`); + sha256(row.source_hash, `${path}.source_hash`); + return row as unknown as ConversationSourceRefV1; +} + +function parseConversationAction(value: unknown, path: string, provider: string, sessionID: string): void { + const row = object(value, path); + exact(row, path, ["revision_id", "source_ref", "kind", "tool_name", "excerpt"]); + id(row.revision_id, `${path}.revision_id`); + parseConversationSourceRef(row.source_ref, `${path}.source_ref`, provider, sessionID); + id(row.kind, `${path}.kind`); + nullableID(row.tool_name, `${path}.tool_name`); + text(row.excerpt, `${path}.excerpt`, 4096); +} + +function parseConversationResult(value: unknown, path: string, provider: string, sessionID: string): void { + const row = object(value, path); + exact(row, path, ["revision_id", "source_ref", "kind", "verification_state", "excerpt"]); + id(row.revision_id, `${path}.revision_id`); + parseConversationSourceRef(row.source_ref, `${path}.source_ref`, provider, sessionID); + id(row.kind, `${path}.kind`); + oneOf(row.verification_state, `${path}.verification_state`, ["unknown", "passed", "failed", "partial"]); + text(row.excerpt, `${path}.excerpt`, 4096); +} + function parseInspectionIdentity(row: JsonObject): void { constant(row.schema_version, 1, "$.schema_version"); version(row.minimum_reader_version, "$.minimum_reader_version"); @@ -764,13 +1146,27 @@ function parseExtractionRun(value: unknown, path: string, projectID: string): An function parseAnnotation(value: unknown, path: string, projectID: string): AgentAnnotationEntryV1 { const row = object(value, path); exact(row, path, [ - "id", "project_id", "entity_id", "field", "status", "text", "generation_id", "schema_version", - "analysis_profile", "agent_run_id", "dependencies", "revision", "created_at", "confirmed_decision_id" + "id", "project_id", "annotation_kind", "entity_id", "field", "status", "text", "generation_id", "schema_version", + "analysis_profile", "agent_run_id", "dependencies", "revision", "created_at", "confirmed_entity_id", + "target_milestone_id", "prompt_schema_version" + ], [ + "id", "project_id", "annotation_kind", "status", "text", "generation_id", "schema_version", "analysis_profile", + "agent_run_id", "dependencies", "revision", "created_at", "confirmed_entity_id" ]); const annotationID = id(row.id, `${path}.id`); if (id(row.project_id, `${path}.project_id`) !== projectID) throw new Error(`${path}.project_id does not match store`); - id(row.entity_id, `${path}.entity_id`); - id(row.field, `${path}.field`); + const kind = oneOf(row.annotation_kind, `${path}.annotation_kind`, ["decision_candidate", "agreement_candidate", "milestone_conclusion_candidate"]); + const hasEntity = Object.prototype.hasOwnProperty.call(row, "entity_id"); + const hasField = Object.prototype.hasOwnProperty.call(row, "field"); + const hasMilestone = Object.prototype.hasOwnProperty.call(row, "target_milestone_id"); + const hasPrompt = Object.prototype.hasOwnProperty.call(row, "prompt_schema_version"); + if (hasEntity) id(row.entity_id, `${path}.entity_id`); + if (hasField) id(row.field, `${path}.field`); + if (hasMilestone) id(row.target_milestone_id, `${path}.target_milestone_id`); + if (hasPrompt) id(row.prompt_schema_version, `${path}.prompt_schema_version`); + if (kind === "milestone_conclusion_candidate" ? hasEntity || hasField || !hasMilestone || !hasPrompt : !hasEntity || !hasField || hasMilestone || hasPrompt) { + throw new Error(`${path} has invalid conditional entity or milestone fields`); + } const status = oneOf(row.status, `${path}.status`, ["pending", "confirmed", "ignored", "not_decision", "stale"]); text(row.text, `${path}.text`, 4096); id(row.generation_id, `${path}.generation_id`); @@ -779,17 +1175,20 @@ function parseAnnotation(value: unknown, path: string, projectID: string): Agent id(row.agent_run_id, `${path}.agent_run_id`); const dependencies = boundedArray(row.dependencies, `${path}.dependencies`, 256); const seenDependencies = new Set(); + let hasSourceTurn = false; for (let index = 0; index < dependencies.length; index += 1) { const dependency = parseAnnotationDependency(dependencies[index], `${path}.dependencies[${index}]`); addUnique(seenDependencies, `${dependency.kind}\u0000${dependency.revision_id}`, "annotation dependency"); + hasSourceTurn ||= dependency.kind === "source_turn"; } + if (kind === "milestone_conclusion_candidate" && !hasSourceTurn) throw new Error(`${path} milestone conclusion has no source-turn dependency`); positiveInteger(row.revision, `${path}.revision`); text(row.created_at, `${path}.created_at`, 128); - const confirmedID = nullableText(row.confirmed_decision_id, `${path}.confirmed_decision_id`, 256); + const confirmedID = nullableID(row.confirmed_entity_id, `${path}.confirmed_entity_id`); if (status === "confirmed") { - if (confirmedID === null || !ID.test(confirmedID)) throw new Error(`confirmed candidate "${annotationID}" has no valid decision`); + if (confirmedID === null) throw new Error(`confirmed candidate "${annotationID}" has no valid entity`); } else if (confirmedID !== null) { - throw new Error(`candidate "${annotationID}" is not confirmed but has a decision`); + throw new Error(`candidate "${annotationID}" is not confirmed but has an entity`); } return row as unknown as AgentAnnotationEntryV1; } @@ -797,7 +1196,7 @@ function parseAnnotation(value: unknown, path: string, projectID: string): Agent function parseAnnotationDependency(value: unknown, path: string): AnnotationDependencyV1 { const row = object(value, path); exact(row, path, ["kind", "revision_id", "digest"]); - oneOf(row.kind, `${path}.kind`, ["observation", "session_view"]); + oneOf(row.kind, `${path}.kind`, ["observation", "session_view", "source_turn"]); id(row.revision_id, `${path}.revision_id`); digest(row.digest, `${path}.digest`); return row as unknown as AnnotationDependencyV1; @@ -1008,6 +1407,11 @@ function nullableText(value: unknown, path: string, maximum: number): string | n return text(value, path, maximum); } +function nullableID(value: unknown, path: string): string | null { + if (value === null) return null; + return id(value, path); +} + function id(value: unknown, path: string): string { const result = text(value, path, 256, true); if (!ID.test(result)) throw new Error(`${path} must be a valid ID`); @@ -1104,6 +1508,10 @@ function identityKey(provider: string, sessionID: string): string { return `${provider}\u0000${sessionID}`; } +function sourceTurnKey(provider: string, sessionID: string, turnID: string): string { + return `${provider}\u0000${sessionID}\u0000${turnID}`; +} + function checkedAdd(left: number, right: number, path: string): number { if (left > MAX_SAFE - right) throw new Error(`${path} addition overflow`); return left + right; @@ -1209,6 +1617,84 @@ function canonicalLedgerSHA256(ledger: MachineLedgerV4): string { return sha256Text(goJSON(body)); } +function canonicalConversationChainDigest(chain: ConversationChainV1): string { + const sourceRef = (ref: ConversationSourceRefV1): JsonObject => ({ + provider: ref.provider, session_id: ref.session_id, source_identity: ref.source_identity, + record_ordinal: ref.record_ordinal, source_hash: ref.source_hash + }); + const message = (item: ConversationMessageV1): JsonObject => ({ + role: item.role, revision_id: item.revision_id, source_ref: sourceRef(item.source_ref), + occurred_at: item.occurred_at, visible_excerpt: item.visible_excerpt, truncated: item.truncated + }); + const body = { + schema_version: chain.schema_version, + minimum_reader_version: chain.minimum_reader_version, + project_id: chain.project_id, + provider: chain.provider, + session_id: chain.session_id, + session_view_digest: chain.session_view_digest, + dependency_digest: chain.dependency_digest, + segmentation_rule_version: chain.segmentation_rule_version, + coverage: { + source_messages: chain.coverage.source_messages, + captured_messages: chain.coverage.captured_messages, + turn_units: chain.coverage.turn_units, + unanswered_units: chain.coverage.unanswered_units, + truncated_messages: chain.coverage.truncated_messages + }, + turn_units: chain.turn_units.map((turn) => ({ + turn_unit_id: turn.turn_unit_id, + ordinal: turn.ordinal, + started_at: turn.started_at, + ended_at: turn.ended_at, + user_message: message(turn.user_message), + assistant_messages: turn.assistant_messages.map(message), + actions: turn.actions.map((item) => ({ + revision_id: item.revision_id, source_ref: sourceRef(item.source_ref), kind: item.kind, + tool_name: item.tool_name, excerpt: item.excerpt + })), + results: turn.results.map((item) => ({ + revision_id: item.revision_id, source_ref: sourceRef(item.source_ref), kind: item.kind, + verification_state: item.verification_state, excerpt: item.excerpt + })), + answer_state: turn.answer_state + })) + }; + return `sha256:${sha256Text(goJSON(body))}`; +} + +function canonicalProblemMapCandidateDigest(store: ProblemMapCandidateV1): string { + const sourceTurn = (ref: SourceTurnRefV4): JsonObject => ({ provider: ref.provider, session_id: ref.session_id, turn_unit_id: ref.turn_unit_id }); + const body = { + schema_version: store.schema_version, + minimum_reader_version: store.minimum_reader_version, + project_id: store.project_id, + candidates: store.candidates.map((candidate) => ({ + candidate_id: candidate.candidate_id, + project_id: candidate.project_id, + question: candidate.question, + source_turn_refs: candidate.source_turn_refs.map(sourceTurn), + recommended_relation: candidate.recommended_relation, + recommended_target_id: candidate.recommended_target_id, + alternate_target_ids: candidate.alternate_target_ids, + related_node_ids: candidate.related_node_ids, + grounds: candidate.grounds.map((ground) => ({ + rule_id: ground.rule_id, rule_version: ground.rule_version, + matched_fact_refs: ground.matched_fact_refs, explanation: ground.explanation + })), + confidence: candidate.confidence, + status: candidate.status, + dependency_digests: candidate.dependency_digests, + analysis_mode: candidate.analysis_mode, + agent_run_id: candidate.agent_run_id, + revision: candidate.revision, + created_at: candidate.created_at, + updated_at: candidate.updated_at + })) + }; + return `sha256:${sha256Text(goJSON(body))}`; +} + function orderedIndexCoverage(value: SessionIndexCoverageV1): SessionIndexCoverageV1 { return { total: value.total, diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index 1368910..741baa3 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -3,13 +3,16 @@ import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { + assertProblemGraph, assertSnapshotBindings, codeOf, parseAgentAnnotationV1, parseCandidateListV1, + parseConversationChainV1, parseMachineLedgerV4, parsePricingSnapshotV1, parsePricingSupplementV1, + parseProblemMapCandidateV1, parseReviewPresentationV4, parseSessionEventPageV1, parseSessionIndexV1, @@ -17,6 +20,7 @@ import { WireRejectionError, type WireRejectionCode } from "../src/data/contracts-v4"; +import type { ViewKind } from "../src/contracts/review-v4"; const here = dirname(fileURLToPath(import.meta.url)); const pluginFixture = (name: string): Promise => @@ -39,7 +43,9 @@ const contracts: ReadonlyArray unknown): WireRejectionError { @@ -340,6 +346,71 @@ describe("session contracts", () => { }); }); +describe("conversation chain and problem map contracts", () => { + it("keeps five view kinds including the formal problems view", () => { + const kinds: ViewKind[] = ["evolution", "problems", "decisions", "sessions", "usage"]; + expect(kinds).toHaveLength(5); + }); + + it("rejects hidden roles, oversized UTF-8 excerpts, and raw tool output keys", async () => { + const chain = await fixtureObject("conversation-chain-v1.valid.json") as { turn_units: JsonObject[] }; + const turn = chain.turn_units[0] as { user_message: JsonObject; actions: JsonObject[] }; + turn.user_message.role = "system"; + expect(() => parseConversationChainV1(JSON.stringify(chain))).toThrow(/role|enum|user/i); + turn.user_message.role = "user"; + turn.user_message.visible_excerpt = "界".repeat(1366); + expect(() => parseConversationChainV1(JSON.stringify(chain))).toThrow(/4096|byte/i); + turn.user_message.visible_excerpt = "question"; + turn.actions[0].raw_tool_output = { secret: true }; + expect(codeOf(captureRejection(() => parseConversationChainV1(JSON.stringify(chain))))).toBe("wire_shape_invalid"); + }); + + it("binds both new contracts to canonical digests that omit only their digest field", async () => { + const chain = await fixtureObject("conversation-chain-v1.valid.json"); + chain.segmentation_rule_version = "visible-turn-v2"; + expect(() => parseConversationChainV1(JSON.stringify(chain))).toThrow(/digest/i); + + const candidates = await fixtureObject("problem-map-candidate-v1.valid.json") as { candidates: JsonObject[] }; + candidates.candidates[0].question = "Tampered question?"; + expect(() => parseProblemMapCandidateV1(JSON.stringify(candidates))).toThrow(/digest/i); + }); + + it("enforces formal problem graph cycles, relations, and sibling order", () => { + const node = (id: string, parent: string | null, order: number): JsonObject => ({ + id, question: `${id}?`, primary_parent_id: parent, related_node_ids: [], workflow_state: "not_started", + answer_state: "no_answer", completion_criterion: "", current_conclusion: "", source_turn_refs: [], + provenance: "human_created", first_proposed_at: "2026-09-04T00:00:00Z", sibling_order: order, + confirmed_at: null, revision: 1 + }); + const cycle = [node("a", "b", 0), node("b", "a", 0)]; + expect(() => assertProblemGraph(cycle as never)).toThrow(/cycle/i); + const missing = [node("a", null, 0), { ...node("b", "a", 0), related_node_ids: ["missing"] }]; + expect(() => assertProblemGraph(missing as never)).toThrow(/missing|related/i); + const siblings = [node("a", null, 0), node("b", "a", 0), node("c", "a", 0)]; + expect(() => assertProblemGraph(siblings as never)).toThrow(/sibling|order|duplicate/i); + }); + + it("requires honest missing conclusions and source-turn-backed milestone annotations", async () => { + const review = await fixtureObject("review-presentation-v4.valid.json") as { timeline: JsonObject[] }; + review.timeline = [{ + id: "m", generation_id: "generation-1", occurred_at: "2026-09-04T00:00:00Z", kind: "milestone", title: "M", summary: "S", decision_ids: [], + closed_loop: { + trigger_question: { state: "missing", text: "", missing_reason: "not_captured", source_turn_refs: [] }, + conclusion: { kind: "missing", text: "invented", missing_reason: "not_captured", source_turn_refs: [] }, + execution: { state: "missing", text: "", missing_reason: "not_captured", source_turn_refs: [] }, + verification: { state: "missing", text: "", missing_reason: "not_captured", source_turn_refs: [] }, + impact_and_follow_up: { state: "missing", text: "", missing_reason: "not_captured", source_turn_refs: [] }, + source_turn_refs: [], coverage: { source_turns: 0, captured_turns: 0, truncated_turns: 0, source_unavailable_turns: 0 } + } + }]; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/missing|conclusion|text/i); + + const annotation = await fixtureObject("agent-annotation-v1.valid.json") as { annotations: JsonObject[] }; + annotation.annotations[0].entity_id = "decision-only"; + expect(() => parseAgentAnnotationV1(JSON.stringify(annotation))).toThrow(/entity|milestone|unknown/i); + }); +}); + describe("pricing and optional-field semantics", () => { it("distinguishes a complete free price from an unknown price", async () => { const snapshot = await fixtureObject("pricing-snapshot-v1.valid.json") as { diff --git a/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json index 9735f12..7abdf27 100644 --- a/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json +++ b/obsidian-plugin/tests/fixtures/v4/agent-annotation-v1.valid.json @@ -1,3 +1,18 @@ { - "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "annotations": [], "extraction_runs": [] + "schema_version": 1, + "minimum_reader_version": "0.4.0", + "project_id": "project-p", + "annotations": [{ + "id": "summary-1", "project_id": "project-p", "annotation_kind": "milestone_conclusion_candidate", "status": "pending", + "text": "The bounded visible answer concluded the milestone.", "generation_id": "generation-1", "schema_version": 1, + "analysis_profile": "milestone-summary-v1", "agent_run_id": "run-1", + "dependencies": [{ "kind": "source_turn", "revision_id": "turn-1", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }], + "revision": 1, "created_at": "2026-09-04T00:00:00Z", "confirmed_entity_id": null, + "target_milestone_id": "milestone-1", "prompt_schema_version": "milestone-conclusion-v1" + }], + "extraction_runs": [{ + "run_id": "run-1", "project_id": "project-p", "status": "completed", "extractor_version": "extractor-v1", + "prompt_schema_version": "milestone-conclusion-v1", "dependency_digests": ["sha256:1111111111111111111111111111111111111111111111111111111111111111"], + "created_at": "2026-09-04T00:00:00Z", "updated_at": "2026-09-04T00:00:01Z" + }] } diff --git a/obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.invalid.json new file mode 100644 index 0000000..2662cb3 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.invalid.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "project_id": "project-p", "provider": "opencode", "session_id": "same-native-id", + "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "dependency_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "segmentation_rule_version": "visible-turn-v1", + "coverage": { "source_messages": 1, "captured_messages": 1, "turn_units": 1, "unanswered_units": 1, "truncated_messages": 0 }, + "turn_units": [{ + "turn_unit_id": "turn-1", "ordinal": 1, "started_at": "2026-09-04T00:00:00Z", "ended_at": null, + "user_message": { + "role": "developer", "revision_id": "revision-hidden-1", + "source_ref": { "provider": "opencode", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 1, "source_hash": "3333333333333333333333333333333333333333333333333333333333333333" }, + "occurred_at": "2026-09-04T00:00:00Z", "visible_excerpt": "hidden instruction", "truncated": false + }, + "assistant_messages": [], "actions": [], "results": [], "answer_state": "no_answer" + }] +} diff --git a/obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.valid.json new file mode 100644 index 0000000..c25d9cd --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/conversation-chain-v1.valid.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:6b047065af598dc399c64ef25f7fad0979665cfbe1976877225fc3e85bbf04a0", + "project_id": "project-p", "provider": "claude", "session_id": "same-native-id", + "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "dependency_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "segmentation_rule_version": "visible-turn-v1", + "coverage": { "source_messages": 2, "captured_messages": 2, "turn_units": 1, "unanswered_units": 0, "truncated_messages": 0 }, + "turn_units": [{ + "turn_unit_id": "turn-1", "ordinal": 1, "started_at": "2026-09-04T00:00:00Z", "ended_at": "2026-09-04T00:00:02Z", + "user_message": { + "role": "user", "revision_id": "revision-user-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 1, "source_hash": "3333333333333333333333333333333333333333333333333333333333333333" }, + "occurred_at": "2026-09-04T00:00:00Z", "visible_excerpt": "How should this problem be handled?", "truncated": false + }, + "assistant_messages": [{ + "role": "assistant", "revision_id": "revision-assistant-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 2, "source_hash": "4444444444444444444444444444444444444444444444444444444444444444" }, + "occurred_at": "2026-09-04T00:00:01Z", "visible_excerpt": "Use a deterministic contract.", "truncated": false + }], + "actions": [{ + "revision_id": "revision-action-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 3, "source_hash": "5555555555555555555555555555555555555555555555555555555555555555" }, + "kind": "tool_call", "tool_name": "go-test", "excerpt": "Run focused tests" + }], + "results": [{ + "revision_id": "revision-result-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 4, "source_hash": "6666666666666666666666666666666666666666666666666666666666666666" }, + "kind": "test", "verification_state": "passed", "excerpt": "PASS" + }], + "answer_state": "answered" + }] +} diff --git a/obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.invalid.json b/obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.invalid.json new file mode 100644 index 0000000..a83ea95 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.invalid.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "project_id": "project-p", + "candidates": [{ + "candidate_id": "candidate-1", "project_id": "project-p", "question": "Where should this question be placed?", + "source_turn_refs": [{ "provider": "opencode", "session_id": "same-native-id", "turn_unit_id": "turn-1" }], + "recommended_relation": "keep_pending", "recommended_target_id": null, "alternate_target_ids": [], "related_node_ids": [], "grounds": [], + "confidence": "low", "status": "pending", + "dependency_digests": ["sha256:1111111111111111111111111111111111111111111111111111111111111111"], + "analysis_mode": "deterministic", "agent_run_id": "run-must-be-null", "revision": 1, + "created_at": "2026-09-04T00:00:00Z", "updated_at": "2026-09-04T00:00:00Z" + }] +} diff --git a/obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.valid.json b/obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.valid.json new file mode 100644 index 0000000..d8d76f1 --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/problem-map-candidate-v1.valid.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:352372889f2be025afe0dfcbb67ef3754aaa75a66fb1f283ec198c776252abca", "project_id": "project-p", + "candidates": [{ + "candidate_id": "candidate-1", "project_id": "project-p", "question": "Where should this question be placed?", + "source_turn_refs": [{ "provider": "opencode", "session_id": "same-native-id", "turn_unit_id": "turn-1" }], + "recommended_relation": "keep_pending", "recommended_target_id": null, "alternate_target_ids": [], "related_node_ids": [], + "grounds": [{ "rule_id": "explicit-reference", "rule_version": "v1", "matched_fact_refs": ["fact-1"], "explanation": "No authenticated parent signal was present." }], + "confidence": "low", "status": "pending", + "dependency_digests": ["sha256:1111111111111111111111111111111111111111111111111111111111111111"], + "analysis_mode": "deterministic", "agent_run_id": null, "revision": 1, + "created_at": "2026-09-04T00:00:00Z", "updated_at": "2026-09-04T00:00:00Z" + }] +} diff --git a/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json index b5fad62..64d77dc 100644 --- a/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json +++ b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.invalid.json @@ -1,5 +1,5 @@ { "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04", "unknown": true }, - "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "problem_map_revision": 0, "problem_root_ids": [], "problem_nodes": [], "chain_dependencies": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] } diff --git a/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json index feb0a10..9d820be 100644 --- a/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json +++ b/obsidian-plugin/tests/fixtures/v4/review-presentation-v4.valid.json @@ -1,5 +1,5 @@ { "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04" }, - "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "problem_map_revision": 0, "problem_root_ids": [], "problem_nodes": [], "chain_dependencies": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] } diff --git a/schemas/agent-annotation-v1.schema.json b/schemas/agent-annotation-v1.schema.json index 23cc417..cce8303 100644 --- a/schemas/agent-annotation-v1.schema.json +++ b/schemas/agent-annotation-v1.schema.json @@ -2,27 +2,65 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sessionreviewer.local/schemas/agent-annotation-v1.schema.json", "title": "SessionReviewer agent annotation store v1", - "type": "object", "additionalProperties": false, + "type": "object", + "additionalProperties": false, "required": ["schema_version", "minimum_reader_version", "project_id", "annotations", "extraction_runs"], "properties": { - "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, + "schema_version": { "const": 1 }, + "minimum_reader_version": { "const": "0.4.0" }, + "project_id": { "$ref": "#/$defs/id" }, "annotations": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/annotation" } }, "extraction_runs": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/run" } } }, "$defs": { - "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "text": { "type": "string", "maxLength": 4096 }, "timestamp": { "type": "string", "maxLength": 128 }, + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, + "text": { "type": "string", "maxLength": 4096 }, + "timestamp": { "type": "string", "maxLength": 128 }, "annotation": { - "type": "object", "additionalProperties": false, - "required": ["id", "project_id", "entity_id", "field", "status", "text", "generation_id", "schema_version", "analysis_profile", "agent_run_id", "dependencies", "revision", "created_at", "confirmed_decision_id"], - "properties": { "id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "status": { "enum": ["pending", "confirmed", "ignored", "not_decision", "stale"] }, "text": { "$ref": "#/$defs/text" }, "generation_id": { "$ref": "#/$defs/id" }, "schema_version": { "const": 1 }, "analysis_profile": { "$ref": "#/$defs/id" }, "agent_run_id": { "$ref": "#/$defs/id" }, "dependencies": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/dependency" } }, "revision": { "type": "integer", "minimum": 1 }, "created_at": { "$ref": "#/$defs/timestamp" }, "confirmed_decision_id": { "type": ["string", "null"], "maxLength": 256 } } + "type": "object", + "additionalProperties": false, + "required": ["id", "project_id", "annotation_kind", "status", "text", "generation_id", "schema_version", "analysis_profile", "agent_run_id", "dependencies", "revision", "created_at", "confirmed_entity_id"], + "properties": { + "id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, + "annotation_kind": { "enum": ["decision_candidate", "agreement_candidate", "milestone_conclusion_candidate"] }, + "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, + "status": { "enum": ["pending", "confirmed", "ignored", "not_decision", "stale"] }, + "text": { "$ref": "#/$defs/text" }, "generation_id": { "$ref": "#/$defs/id" }, + "schema_version": { "const": 1 }, "analysis_profile": { "$ref": "#/$defs/id" }, + "agent_run_id": { "$ref": "#/$defs/id" }, + "dependencies": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/dependency" } }, + "revision": { "type": "integer", "minimum": 1 }, "created_at": { "$ref": "#/$defs/timestamp" }, + "confirmed_entity_id": { "oneOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] }, + "target_milestone_id": { "$ref": "#/$defs/id" }, "prompt_schema_version": { "$ref": "#/$defs/id" } + }, + "allOf": [ + { + "if": { "properties": { "annotation_kind": { "const": "milestone_conclusion_candidate" } }, "required": ["annotation_kind"] }, + "then": { "required": ["target_milestone_id", "prompt_schema_version"], "not": { "anyOf": [{ "required": ["entity_id"] }, { "required": ["field"] }] } }, + "else": { "required": ["entity_id", "field"], "not": { "anyOf": [{ "required": ["target_milestone_id"] }, { "required": ["prompt_schema_version"] }] } } + }, + { + "if": { "properties": { "status": { "const": "confirmed" } }, "required": ["status"] }, + "then": { "properties": { "confirmed_entity_id": { "$ref": "#/$defs/id" } } }, + "else": { "properties": { "confirmed_entity_id": { "type": "null" } } } + } + ] }, "dependency": { "type": "object", "additionalProperties": false, "required": ["kind", "revision_id", "digest"], - "properties": { "kind": { "enum": ["observation", "session_view"] }, "revision_id": { "$ref": "#/$defs/id" }, "digest": { "$ref": "#/$defs/digest" } } + "properties": { "kind": { "enum": ["observation", "session_view", "source_turn"] }, "revision_id": { "$ref": "#/$defs/id" }, "digest": { "$ref": "#/$defs/digest" } } }, "run": { - "type": "object", "additionalProperties": false, "required": ["run_id", "project_id", "status", "extractor_version", "prompt_schema_version", "dependency_digests", "created_at", "updated_at"], - "properties": { "run_id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, "status": { "enum": ["pending", "running", "completed", "failed", "cancelled"] }, "extractor_version": { "$ref": "#/$defs/id" }, "prompt_schema_version": { "$ref": "#/$defs/id" }, "dependency_digests": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/digest" } }, "created_at": { "$ref": "#/$defs/timestamp" }, "updated_at": { "$ref": "#/$defs/timestamp" } } + "type": "object", "additionalProperties": false, + "required": ["run_id", "project_id", "status", "extractor_version", "prompt_schema_version", "dependency_digests", "created_at", "updated_at"], + "properties": { + "run_id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, + "status": { "enum": ["pending", "running", "completed", "failed", "cancelled"] }, + "extractor_version": { "$ref": "#/$defs/id" }, "prompt_schema_version": { "$ref": "#/$defs/id" }, + "dependency_digests": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/digest" } }, + "created_at": { "$ref": "#/$defs/timestamp" }, "updated_at": { "$ref": "#/$defs/timestamp" } + } } } } diff --git a/schemas/conversation-chain-v1.schema.json b/schemas/conversation-chain-v1.schema.json new file mode 100644 index 0000000..4bffa61 --- /dev/null +++ b/schemas/conversation-chain-v1.schema.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/conversation-chain-v1.schema.json", + "title": "SessionReviewer private conversation chain v1", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "digest", "project_id", "provider", "session_id", "session_view_digest", "dependency_digest", "segmentation_rule_version", "coverage", "turn_units"], + "properties": { + "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, "digest": { "$ref": "#/$defs/digest" }, + "project_id": { "$ref": "#/$defs/id" }, "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, + "session_view_digest": { "$ref": "#/$defs/digest" }, "dependency_digest": { "$ref": "#/$defs/digest" }, + "segmentation_rule_version": { "$ref": "#/$defs/id" }, "coverage": { "$ref": "#/$defs/coverage" }, + "turn_units": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/turn_unit" } } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, "excerpt": { "type": "string", "maxLength": 4096 }, + "source_ref": { + "type": "object", "additionalProperties": false, + "required": ["provider", "session_id", "source_identity", "record_ordinal", "source_hash"], + "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "source_identity": { "$ref": "#/$defs/id" }, "record_ordinal": { "type": "integer", "minimum": 0 }, "source_hash": { "$ref": "#/$defs/sha256" } } + }, + "message": { + "type": "object", "additionalProperties": false, + "required": ["role", "revision_id", "source_ref", "occurred_at", "visible_excerpt", "truncated"], + "properties": { "role": { "enum": ["user", "assistant"] }, "revision_id": { "$ref": "#/$defs/id" }, "source_ref": { "$ref": "#/$defs/source_ref" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "visible_excerpt": { "$ref": "#/$defs/excerpt" }, "truncated": { "type": "boolean" } } + }, + "action": { + "type": "object", "additionalProperties": false, + "required": ["revision_id", "source_ref", "kind", "tool_name", "excerpt"], + "properties": { "revision_id": { "$ref": "#/$defs/id" }, "source_ref": { "$ref": "#/$defs/source_ref" }, "kind": { "$ref": "#/$defs/id" }, "tool_name": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "excerpt": { "$ref": "#/$defs/excerpt" } } + }, + "result": { + "type": "object", "additionalProperties": false, + "required": ["revision_id", "source_ref", "kind", "verification_state", "excerpt"], + "properties": { "revision_id": { "$ref": "#/$defs/id" }, "source_ref": { "$ref": "#/$defs/source_ref" }, "kind": { "$ref": "#/$defs/id" }, "verification_state": { "enum": ["unknown", "passed", "failed", "partial"] }, "excerpt": { "$ref": "#/$defs/excerpt" } } + }, + "turn_unit": { + "type": "object", "additionalProperties": false, + "required": ["turn_unit_id", "ordinal", "started_at", "ended_at", "user_message", "assistant_messages", "actions", "results", "answer_state"], + "properties": { + "turn_unit_id": { "$ref": "#/$defs/id" }, "ordinal": { "type": "integer", "minimum": 1 }, "started_at": { "$ref": "#/$defs/timestamp" }, + "ended_at": { "type": ["string", "null"], "minLength": 1, "maxLength": 128 }, "user_message": { "$ref": "#/$defs/message" }, + "assistant_messages": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/message" } }, + "actions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/action" } }, + "results": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/result" } }, + "answer_state": { "enum": ["no_answer", "answered", "partial"] } + } + }, + "coverage": { + "type": "object", "additionalProperties": false, + "required": ["source_messages", "captured_messages", "turn_units", "unanswered_units", "truncated_messages"], + "properties": { "source_messages": { "type": "integer", "minimum": 0 }, "captured_messages": { "type": "integer", "minimum": 0 }, "turn_units": { "type": "integer", "minimum": 0 }, "unanswered_units": { "type": "integer", "minimum": 0 }, "truncated_messages": { "type": "integer", "minimum": 0 } } + } + } +} diff --git a/schemas/problem-map-candidate-v1.schema.json b/schemas/problem-map-candidate-v1.schema.json new file mode 100644 index 0000000..462d83b --- /dev/null +++ b/schemas/problem-map-candidate-v1.schema.json @@ -0,0 +1,39 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://sessionreviewer.local/schemas/problem-map-candidate-v1.schema.json", + "title": "SessionReviewer private problem placement candidates v1", + "type": "object", "additionalProperties": false, + "required": ["schema_version", "minimum_reader_version", "digest", "project_id", "candidates"], + "properties": { + "schema_version": { "const": 1 }, "minimum_reader_version": { "const": "0.4.0" }, "digest": { "$ref": "#/$defs/digest" }, + "project_id": { "$ref": "#/$defs/id" }, "candidates": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/candidate" } } + }, + "$defs": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "text": { "type": "string", "maxLength": 4096 }, "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, + "id_array": { "type": "array", "maxItems": 2, "items": { "$ref": "#/$defs/id" } }, + "source_turn_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "turn_unit_id"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "turn_unit_id": { "$ref": "#/$defs/id" } } }, + "ground": { "type": "object", "additionalProperties": false, "required": ["rule_id", "rule_version", "matched_fact_refs", "explanation"], "properties": { "rule_id": { "$ref": "#/$defs/id" }, "rule_version": { "$ref": "#/$defs/id" }, "matched_fact_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/id" } }, "explanation": { "$ref": "#/$defs/text" } } }, + "candidate": { + "type": "object", "additionalProperties": false, + "required": ["candidate_id", "project_id", "question", "source_turn_refs", "recommended_relation", "recommended_target_id", "alternate_target_ids", "related_node_ids", "grounds", "confidence", "status", "dependency_digests", "analysis_mode", "agent_run_id", "revision", "created_at", "updated_at"], + "properties": { + "candidate_id": { "$ref": "#/$defs/id" }, "project_id": { "$ref": "#/$defs/id" }, "question": { "$ref": "#/$defs/text" }, + "source_turn_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/source_turn_ref" } }, + "recommended_relation": { "enum": ["child", "sibling", "merge", "keep_pending"] }, + "recommended_target_id": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "alternate_target_ids": { "$ref": "#/$defs/id_array" }, "related_node_ids": { "$ref": "#/$defs/id_array" }, + "grounds": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/ground" } }, + "confidence": { "enum": ["high", "medium", "low"] }, "status": { "enum": ["pending", "applied", "merged", "kept_pending", "stale", "dismissed"] }, + "dependency_digests": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/digest" } }, + "analysis_mode": { "enum": ["deterministic", "agent_requested"] }, + "agent_run_id": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, + "revision": { "type": "integer", "minimum": 1 }, "created_at": { "$ref": "#/$defs/timestamp" }, "updated_at": { "$ref": "#/$defs/timestamp" } + }, + "allOf": [ + { "if": { "properties": { "analysis_mode": { "const": "deterministic" } } }, "then": { "properties": { "agent_run_id": { "const": null } } }, "else": { "properties": { "agent_run_id": { "type": "string" } } } }, + { "if": { "properties": { "recommended_relation": { "const": "keep_pending" } } }, "then": { "properties": { "recommended_target_id": { "const": null } } }, "else": { "properties": { "recommended_target_id": { "type": "string" } } } } + ] + } + } +} diff --git a/schemas/review-presentation-v4.schema.json b/schemas/review-presentation-v4.schema.json index cf7d8cb..e821e24 100644 --- a/schemas/review-presentation-v4.schema.json +++ b/schemas/review-presentation-v4.schema.json @@ -3,22 +3,33 @@ "$id": "https://sessionreviewer.local/schemas/review-presentation-v4.schema.json", "title": "SessionReviewer human review presentation v4", "type": "object", "additionalProperties": false, - "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "revision", "current_state", "timeline", "decisions", "risks", "open_loops", "human_patches", "orphan_patches", "generated_baselines"], + "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "revision", "current_state", "timeline", "decisions", "risks", "open_loops", "problem_map_revision", "problem_root_ids", "problem_nodes", "chain_dependencies", "human_patches", "orphan_patches", "generated_baselines"], "properties": { "schema_version": { "const": 4 }, "minimum_reader_version": { "const": "0.4.0" }, "minimum_writer_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "revision": { "type": "integer", "minimum": 0 }, "current_state": { "$ref": "#/$defs/current_state" }, "timeline": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/timeline" } }, "decisions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/decision" } }, "risks": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/risk" } }, "open_loops": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/open_loop" } }, + "problem_map_revision": { "type": "integer", "minimum": 0 }, "problem_root_ids": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, + "problem_nodes": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/problem_node" } }, "chain_dependencies": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/chain_dependency" } }, "human_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "orphan_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "generated_baselines": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/baseline" } } }, "$defs": { - "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "strings": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "excerpt": { "type": "string", "maxLength": 4096 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "strings": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, "current_state": { "type": "object", "additionalProperties": false, "required": ["goal", "stage", "status", "next_action", "last_verification"], "properties": { "goal": { "$ref": "#/$defs/text" }, "stage": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "next_action": { "$ref": "#/$defs/text" }, "last_verification": { "$ref": "#/$defs/text" } } }, - "timeline": { "type": "object", "additionalProperties": false, "required": ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids"], "properties": { "id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "kind": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "summary": { "$ref": "#/$defs/text" }, "decision_ids": { "$ref": "#/$defs/id_array" } } }, + "source_turn_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "turn_unit_id"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "turn_unit_id": { "$ref": "#/$defs/id" } } }, + "source_turn_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/source_turn_ref" } }, + "missing_reason": { "type": ["string", "null"], "enum": ["not_captured", "no_visible_answer", "no_execution_evidence", "not_verified", "source_unavailable", "partial_coverage", null] }, + "closed_loop_segment": { "type": "object", "additionalProperties": false, "required": ["state", "text", "missing_reason", "source_turn_refs"], "properties": { "state": { "enum": ["present", "partial", "missing"] }, "text": { "$ref": "#/$defs/text" }, "missing_reason": { "$ref": "#/$defs/missing_reason" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" } }, "allOf": [{ "if": { "properties": { "state": { "const": "missing" } }, "required": ["state"] }, "then": { "properties": { "text": { "const": "" }, "missing_reason": { "type": "string" } } }, "else": { "properties": { "text": { "type": "string", "minLength": 1 }, "missing_reason": { "const": null } } } }] }, + "closed_loop_conclusion": { "type": "object", "additionalProperties": false, "required": ["kind", "text", "missing_reason", "source_turn_refs"], "properties": { "kind": { "enum": ["visible_answer_excerpt", "human_confirmed", "ai_candidate_confirmed", "missing"] }, "text": { "$ref": "#/$defs/text" }, "missing_reason": { "$ref": "#/$defs/missing_reason" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" } }, "allOf": [{ "if": { "properties": { "kind": { "const": "missing" } }, "required": ["kind"] }, "then": { "properties": { "text": { "const": "" }, "missing_reason": { "type": "string" } } }, "else": { "properties": { "text": { "type": "string", "minLength": 1 }, "missing_reason": { "const": null } } } }, { "if": { "properties": { "kind": { "const": "visible_answer_excerpt" } }, "required": ["kind"] }, "then": { "properties": { "text": { "type": "string", "maxLength": 4096 } } } }] }, + "closed_loop_coverage": { "type": "object", "additionalProperties": false, "required": ["source_turns", "captured_turns", "truncated_turns", "source_unavailable_turns"], "properties": { "source_turns": { "type": "integer", "minimum": 0 }, "captured_turns": { "type": "integer", "minimum": 0 }, "truncated_turns": { "type": "integer", "minimum": 0 }, "source_unavailable_turns": { "type": "integer", "minimum": 0 } } }, + "closed_loop": { "type": "object", "additionalProperties": false, "required": ["trigger_question", "conclusion", "execution", "verification", "impact_and_follow_up", "source_turn_refs", "coverage"], "properties": { "trigger_question": { "$ref": "#/$defs/closed_loop_segment" }, "conclusion": { "$ref": "#/$defs/closed_loop_conclusion" }, "execution": { "$ref": "#/$defs/closed_loop_segment" }, "verification": { "$ref": "#/$defs/closed_loop_segment" }, "impact_and_follow_up": { "$ref": "#/$defs/closed_loop_segment" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" }, "coverage": { "$ref": "#/$defs/closed_loop_coverage" } } }, + "timeline": { "type": "object", "additionalProperties": false, "required": ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids", "closed_loop"], "properties": { "id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "kind": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "summary": { "$ref": "#/$defs/text" }, "decision_ids": { "$ref": "#/$defs/id_array" }, "closed_loop": { "$ref": "#/$defs/closed_loop" } } }, "decision": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "occurred_at", "title", "rationale", "impact", "status", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "kind": { "enum": ["decision", "agreement"] }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "title": { "$ref": "#/$defs/text" }, "rationale": { "$ref": "#/$defs/text" }, "impact": { "$ref": "#/$defs/text" }, "status": { "enum": ["active", "superseded", "archived"] }, "reevaluate_when": { "$ref": "#/$defs/text" }, "supersedes": { "$ref": "#/$defs/id_array" }, "milestone_ids": { "$ref": "#/$defs/id_array" }, "session_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/session_ref" } }, "provenance": { "enum": ["human_created", "migrated", "ai_candidate_confirmed"] }, "pinned": { "type": "boolean" }, "revision": { "type": "integer", "minimum": 1 } } }, "session_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" } } }, "risk": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "detail"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "detail": { "$ref": "#/$defs/text" } } }, "open_loop": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "question", "next_experiment", "completion_criterion"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "question": { "$ref": "#/$defs/text" }, "next_experiment": { "$ref": "#/$defs/text" }, "completion_criterion": { "$ref": "#/$defs/text" } } }, + "problem_node": { "type": "object", "additionalProperties": false, "required": ["id", "question", "primary_parent_id", "related_node_ids", "workflow_state", "answer_state", "completion_criterion", "current_conclusion", "source_turn_refs", "provenance", "first_proposed_at", "sibling_order", "confirmed_at", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "question": { "$ref": "#/$defs/excerpt" }, "primary_parent_id": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "related_node_ids": { "type": "array", "maxItems": 2, "items": { "$ref": "#/$defs/id" } }, "workflow_state": { "enum": ["not_started", "in_progress", "paused", "resolved"] }, "answer_state": { "enum": ["no_answer", "answered_unverified", "execution_verified"] }, "completion_criterion": { "$ref": "#/$defs/text" }, "current_conclusion": { "$ref": "#/$defs/text" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" }, "provenance": { "enum": ["human_created", "migrated", "candidate_confirmed"] }, "first_proposed_at": { "$ref": "#/$defs/timestamp" }, "sibling_order": { "type": "integer", "minimum": 0 }, "confirmed_at": { "type": ["string", "null"], "maxLength": 128 }, "revision": { "type": "integer", "minimum": 1 } } }, + "chain_dependency": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "session_view_digest", "dependency_digest", "turn_unit_ids"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "session_view_digest": { "$ref": "#/$defs/digest" }, "dependency_digest": { "$ref": "#/$defs/digest" }, "turn_unit_ids": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } } } }, "patch": { "type": "object", "additionalProperties": false, "required": ["entity_id", "field", "operation", "base_generated_hash"], "properties": { "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "operation": { "enum": ["set", "suppress", "restore_default"] }, "value": { "$ref": "#/$defs/text" }, "values": { "$ref": "#/$defs/strings" }, "base_generated_hash": { "$ref": "#/$defs/sha256" } } }, "baseline": { "type": "object", "additionalProperties": false, "required": ["generation_id", "entity_id", "field", "kind", "generated_hash"], "properties": { "generation_id": { "$ref": "#/$defs/id" }, "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "kind": { "$ref": "#/$defs/id" }, "value": { "$ref": "#/$defs/text" }, "values": { "$ref": "#/$defs/strings" }, "generated_hash": { "$ref": "#/$defs/sha256" } } }, "id_array": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/id" } } diff --git a/testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json index c97eb64..6b1ce45 100644 --- a/testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json +++ b/testdata/contracts/migration/partial/docs/session-review/.session-reviewer/ledger.json @@ -1 +1 @@ -{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"f4f61cd8e805ed82aa25f1fbeeaeb67baa838352567409e26036b99dda3ce88c","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} \ No newline at end of file +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"27a788f113bcea9497fadf68d4f9e9ea77a5307aff74ed9680181b7e5006ad6b","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"27a788f113bcea9497fadf68d4f9e9ea77a5307aff74ed9680181b7e5006ad6b","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"76c6c738456b8a6f266111a7a09737bf7384d7d652f07041e070a8ed3fc7e1cb","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} diff --git "a/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" index 1ca95b5..d857cb3 100644 --- "a/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" +++ "b/testdata/contracts/migration/partial/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -1 +1 @@ -{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","revision":3,"current_state":{"goal":"Preserve","stage":"migration","status":"active","next_action":"verify","last_verification":"2026-09-04"},"timeline":[],"decisions":[],"risks":[],"open_loops":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[]} +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","revision":3,"current_state":{"goal":"Preserve","stage":"migration","status":"active","next_action":"verify","last_verification":"2026-09-04"},"timeline":[],"decisions":[],"risks":[],"open_loops":[],"problem_map_revision":0,"problem_root_ids":[],"problem_nodes":[],"chain_dependencies":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[]} diff --git a/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json b/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json index c97eb64..6b1ce45 100644 --- a/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json +++ b/testdata/contracts/migration/v4/docs/session-review/.session-reviewer/ledger.json @@ -1 +1 @@ -{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"a63d11b41586032ff1ed76020a520e51b95f1e7d9851b96a402455acbf2000d4","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"f4f61cd8e805ed82aa25f1fbeeaeb67baa838352567409e26036b99dda3ce88c","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} \ No newline at end of file +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","accepted_revision":3,"review_sha256":"27a788f113bcea9497fadf68d4f9e9ea77a5307aff74ed9680181b7e5006ad6b","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","accounting":{"total_duration_ms":0,"total_tokens":0,"total_cost_usd":null,"models":[]},"sessions":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[],"pricing_snapshots":[],"current_pricing_snapshot_ids":[],"sync_hashes":{"review_sha256":"27a788f113bcea9497fadf68d4f9e9ea77a5307aff74ed9680181b7e5006ad6b","history_sha256":"bf48faeb4b0de73721ccc46a615aecdb3ef3fa1c497b5340f1b2ee323eaacbdd","ledger_sha256":"76c6c738456b8a6f266111a7a09737bf7384d7d652f07041e070a8ed3fc7e1cb","session_index_digest":"sha256:6a5328670e7430258bb5d6ba0208b5e72d2cd3a6cf78b0c69e824ecb65f74f6a"}} diff --git "a/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" "b/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" index 1ca95b5..d857cb3 100644 --- "a/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" +++ "b/testdata/contracts/migration/v4/docs/session-review/\351\241\271\347\233\256\345\233\236\351\241\276.md" @@ -1 +1 @@ -{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","revision":3,"current_state":{"goal":"Preserve","stage":"migration","status":"active","next_action":"verify","last_verification":"2026-09-04"},"timeline":[],"decisions":[],"risks":[],"open_loops":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[]} +{"schema_version":4,"minimum_reader_version":"0.4.0","minimum_writer_version":"0.4.0","project_id":"project-compatibility","generation_id":"generation-compatibility","project_view_digest":"sha256:1111111111111111111111111111111111111111111111111111111111111111","revision":3,"current_state":{"goal":"Preserve","stage":"migration","status":"active","next_action":"verify","last_verification":"2026-09-04"},"timeline":[],"decisions":[],"risks":[],"open_loops":[],"problem_map_revision":0,"problem_root_ids":[],"problem_nodes":[],"chain_dependencies":[],"human_patches":[],"orphan_patches":[],"generated_baselines":[]} diff --git a/testdata/contracts/v4/agent-annotation-v1.valid.json b/testdata/contracts/v4/agent-annotation-v1.valid.json index 9735f12..7abdf27 100644 --- a/testdata/contracts/v4/agent-annotation-v1.valid.json +++ b/testdata/contracts/v4/agent-annotation-v1.valid.json @@ -1,3 +1,18 @@ { - "schema_version": 1, "minimum_reader_version": "0.4.0", "project_id": "project-p", "annotations": [], "extraction_runs": [] + "schema_version": 1, + "minimum_reader_version": "0.4.0", + "project_id": "project-p", + "annotations": [{ + "id": "summary-1", "project_id": "project-p", "annotation_kind": "milestone_conclusion_candidate", "status": "pending", + "text": "The bounded visible answer concluded the milestone.", "generation_id": "generation-1", "schema_version": 1, + "analysis_profile": "milestone-summary-v1", "agent_run_id": "run-1", + "dependencies": [{ "kind": "source_turn", "revision_id": "turn-1", "digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111" }], + "revision": 1, "created_at": "2026-09-04T00:00:00Z", "confirmed_entity_id": null, + "target_milestone_id": "milestone-1", "prompt_schema_version": "milestone-conclusion-v1" + }], + "extraction_runs": [{ + "run_id": "run-1", "project_id": "project-p", "status": "completed", "extractor_version": "extractor-v1", + "prompt_schema_version": "milestone-conclusion-v1", "dependency_digests": ["sha256:1111111111111111111111111111111111111111111111111111111111111111"], + "created_at": "2026-09-04T00:00:00Z", "updated_at": "2026-09-04T00:00:01Z" + }] } diff --git a/testdata/contracts/v4/conversation-chain-v1.invalid.json b/testdata/contracts/v4/conversation-chain-v1.invalid.json new file mode 100644 index 0000000..2662cb3 --- /dev/null +++ b/testdata/contracts/v4/conversation-chain-v1.invalid.json @@ -0,0 +1,17 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", + "project_id": "project-p", "provider": "opencode", "session_id": "same-native-id", + "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "dependency_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "segmentation_rule_version": "visible-turn-v1", + "coverage": { "source_messages": 1, "captured_messages": 1, "turn_units": 1, "unanswered_units": 1, "truncated_messages": 0 }, + "turn_units": [{ + "turn_unit_id": "turn-1", "ordinal": 1, "started_at": "2026-09-04T00:00:00Z", "ended_at": null, + "user_message": { + "role": "developer", "revision_id": "revision-hidden-1", + "source_ref": { "provider": "opencode", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 1, "source_hash": "3333333333333333333333333333333333333333333333333333333333333333" }, + "occurred_at": "2026-09-04T00:00:00Z", "visible_excerpt": "hidden instruction", "truncated": false + }, + "assistant_messages": [], "actions": [], "results": [], "answer_state": "no_answer" + }] +} diff --git a/testdata/contracts/v4/conversation-chain-v1.valid.json b/testdata/contracts/v4/conversation-chain-v1.valid.json new file mode 100644 index 0000000..c25d9cd --- /dev/null +++ b/testdata/contracts/v4/conversation-chain-v1.valid.json @@ -0,0 +1,32 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:6b047065af598dc399c64ef25f7fad0979665cfbe1976877225fc3e85bbf04a0", + "project_id": "project-p", "provider": "claude", "session_id": "same-native-id", + "session_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", + "dependency_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", + "segmentation_rule_version": "visible-turn-v1", + "coverage": { "source_messages": 2, "captured_messages": 2, "turn_units": 1, "unanswered_units": 0, "truncated_messages": 0 }, + "turn_units": [{ + "turn_unit_id": "turn-1", "ordinal": 1, "started_at": "2026-09-04T00:00:00Z", "ended_at": "2026-09-04T00:00:02Z", + "user_message": { + "role": "user", "revision_id": "revision-user-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 1, "source_hash": "3333333333333333333333333333333333333333333333333333333333333333" }, + "occurred_at": "2026-09-04T00:00:00Z", "visible_excerpt": "How should this problem be handled?", "truncated": false + }, + "assistant_messages": [{ + "role": "assistant", "revision_id": "revision-assistant-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 2, "source_hash": "4444444444444444444444444444444444444444444444444444444444444444" }, + "occurred_at": "2026-09-04T00:00:01Z", "visible_excerpt": "Use a deterministic contract.", "truncated": false + }], + "actions": [{ + "revision_id": "revision-action-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 3, "source_hash": "5555555555555555555555555555555555555555555555555555555555555555" }, + "kind": "tool_call", "tool_name": "go-test", "excerpt": "Run focused tests" + }], + "results": [{ + "revision_id": "revision-result-1", + "source_ref": { "provider": "claude", "session_id": "same-native-id", "source_identity": "source-1", "record_ordinal": 4, "source_hash": "6666666666666666666666666666666666666666666666666666666666666666" }, + "kind": "test", "verification_state": "passed", "excerpt": "PASS" + }], + "answer_state": "answered" + }] +} diff --git a/testdata/contracts/v4/problem-map-candidate-v1.invalid.json b/testdata/contracts/v4/problem-map-candidate-v1.invalid.json new file mode 100644 index 0000000..a83ea95 --- /dev/null +++ b/testdata/contracts/v4/problem-map-candidate-v1.invalid.json @@ -0,0 +1,12 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "project_id": "project-p", + "candidates": [{ + "candidate_id": "candidate-1", "project_id": "project-p", "question": "Where should this question be placed?", + "source_turn_refs": [{ "provider": "opencode", "session_id": "same-native-id", "turn_unit_id": "turn-1" }], + "recommended_relation": "keep_pending", "recommended_target_id": null, "alternate_target_ids": [], "related_node_ids": [], "grounds": [], + "confidence": "low", "status": "pending", + "dependency_digests": ["sha256:1111111111111111111111111111111111111111111111111111111111111111"], + "analysis_mode": "deterministic", "agent_run_id": "run-must-be-null", "revision": 1, + "created_at": "2026-09-04T00:00:00Z", "updated_at": "2026-09-04T00:00:00Z" + }] +} diff --git a/testdata/contracts/v4/problem-map-candidate-v1.valid.json b/testdata/contracts/v4/problem-map-candidate-v1.valid.json new file mode 100644 index 0000000..d8d76f1 --- /dev/null +++ b/testdata/contracts/v4/problem-map-candidate-v1.valid.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:352372889f2be025afe0dfcbb67ef3754aaa75a66fb1f283ec198c776252abca", "project_id": "project-p", + "candidates": [{ + "candidate_id": "candidate-1", "project_id": "project-p", "question": "Where should this question be placed?", + "source_turn_refs": [{ "provider": "opencode", "session_id": "same-native-id", "turn_unit_id": "turn-1" }], + "recommended_relation": "keep_pending", "recommended_target_id": null, "alternate_target_ids": [], "related_node_ids": [], + "grounds": [{ "rule_id": "explicit-reference", "rule_version": "v1", "matched_fact_refs": ["fact-1"], "explanation": "No authenticated parent signal was present." }], + "confidence": "low", "status": "pending", + "dependency_digests": ["sha256:1111111111111111111111111111111111111111111111111111111111111111"], + "analysis_mode": "deterministic", "agent_run_id": null, "revision": 1, + "created_at": "2026-09-04T00:00:00Z", "updated_at": "2026-09-04T00:00:00Z" + }] +} diff --git a/testdata/contracts/v4/review-presentation-v4.invalid.json b/testdata/contracts/v4/review-presentation-v4.invalid.json index b5fad62..64d77dc 100644 --- a/testdata/contracts/v4/review-presentation-v4.invalid.json +++ b/testdata/contracts/v4/review-presentation-v4.invalid.json @@ -1,5 +1,5 @@ { "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04", "unknown": true }, - "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "problem_map_revision": 0, "problem_root_ids": [], "problem_nodes": [], "chain_dependencies": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] } diff --git a/testdata/contracts/v4/review-presentation-v4.valid.json b/testdata/contracts/v4/review-presentation-v4.valid.json index feb0a10..9d820be 100644 --- a/testdata/contracts/v4/review-presentation-v4.valid.json +++ b/testdata/contracts/v4/review-presentation-v4.valid.json @@ -1,5 +1,5 @@ { "schema_version": 4, "minimum_reader_version": "0.4.0", "minimum_writer_version": "0.4.0", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "revision": 1, "current_state": { "goal": "Build", "stage": "implementation", "status": "active", "next_action": "Test", "last_verification": "2026-09-04" }, - "timeline": [], "decisions": [], "risks": [], "open_loops": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] + "timeline": [], "decisions": [], "risks": [], "open_loops": [], "problem_map_revision": 0, "problem_root_ids": [], "problem_nodes": [], "chain_dependencies": [], "human_patches": [], "orphan_patches": [], "generated_baselines": [] } From f21ca3ecf30f6142678bf4d7e33d1a3f6b026e74 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 01:15:02 +0800 Subject: [PATCH 17/25] docs: record extended Gate 0 evidence --- docs/session-review/gate-0-evidence.md | 121 ++++++++++--------------- 1 file changed, 50 insertions(+), 71 deletions(-) diff --git a/docs/session-review/gate-0-evidence.md b/docs/session-review/gate-0-evidence.md index 20454b0..12ec84c 100644 --- a/docs/session-review/gate-0-evidence.md +++ b/docs/session-review/gate-0-evidence.md @@ -2,102 +2,81 @@ ## 结论 -**SUPERSEDED BY CONTRACT EXTENSION / GATE 0 REOPENED** +**LOCAL COMPLETE / WINDOWS CI PENDING** -以下记录仍证明原八组合同在当前 macOS 工作树通过,但 2026-09-04 后续确认的 `conversation-chain-v1`、`problem-map-candidate-v1`、正式问题图和演进闭环扩展尚未包含在该矩阵中。Gate 0 因此重新打开;必须完成 `2026-09-04-obsidian-context-gate-0-contracts.md` Task 7 并重跑完整门禁。由于审计提交尚未推送且没有 Pull Request,`.github/workflows/ci.yml` 中 `windows-x64` / `windows-latest` 原生执行证据也仍不存在。 +本地 Gate 0 已在实现提交 `e3ff49beb6cb28d4aacb73a5ba4f45c43289b112` 上重新通过。合同矩阵现已覆盖 `conversation-chain-v1`、`problem-map-candidate-v1`、正式问题图、演进闭环和通用 Agent annotation。该提交未推送,也没有原生 Windows 运行,因此 Windows CI 证据仍为 PENDING。 ## 审计对象 - 分支:`codex/obsidian-context-v4` -- 实现提交:`3e8337e98a21b7cdc7a2fed0152008f4f79db87d` -- 合并基准:`ea5b1ba950cf03ecfa26353873f10c9aeeb1ffa4` (`origin/main`, tag `0.3.5`) -- 开始审计时 `git status --short`:无输出,工作树干净 -- 本地环境:`Darwin arm64`,`go1.26.5 darwin/arm64`,Node `v24.18.0`,npm `11.16.0` +- 任务基准:`6421a9aad6d7a65bbaef2fa71e4d7e7be3431db6` +- 实现提交:`e3ff49beb6cb28d4aacb73a5ba4f45c43289b112` +- 环境:`Darwin arm64`,`go1.26.5 darwin/arm64`,Node `v24.18.0`,npm `11.16.0` +- 实现提交统计:43 files changed,2,977 insertions,80 deletions +- 未纳入任务提交:既有未跟踪目录 `.superpowers/brainstorm/` -## 完整本地门禁 - -| 命令 | 结果 | 证据 | -|---|---|---| -| `go test -p 1 -timeout 5m -count=1 ./...` | PASS | `go list ./...` 共 55 个 package;最慢的可见 package 为 `internal/scan` 130.429s,其次为 `internal/reviewjob` 72.177s,均低于每个测试二进制的 5 分钟超时;`test/zerotoken` 53.044s 收尾 | -| `go vet ./...` | PASS | exit 0,无输出 | -| `go mod tidy -diff` | PASS | exit 0,无 diff | -| `git diff --check` | PASS | exit 0,无输出 | -| `cd obsidian-plugin && npm run check` | PASS | lint 通过;17/17 test files、111/111 tests;TypeScript typecheck 和 production bundle 通过 | - -本地完整 Go 门禁使用串行 `-p 1`,与 Gate 0 ledger 中已记录的 macOS 文件系统 I/O 争用裁决一致。CI 仍保持原生并行命令。 +## TDD 边界 -## 八组 fixture 与稳定拒绝码 - -架构级 schema fixture 门禁: +规定 RED 命令: ```text -go test ./internal/memory -run '^TestV4ContractFixtures$' -count=1 -v +go test ./internal/conversationchain ./internal/problemmap ./internal/reviewv4 -count=1 && (cd obsidian-plugin && npx vitest run tests/contracts-v4.test.ts) ``` -结果:PASS;1 个父测试和 8/8 个命名子测试通过,每组 valid fixture 被接受,invalid fixture 被拒绝。 - -Go 生产解析器稳定码矩阵门禁: +结果:FAIL(预期)。Go 编译器报告 `conversationchain.Parse/Render/Validate/Document/SourceRef`、`problemmap.ParseCandidates/ValidateCandidates/RenderCandidates/CandidateStore`、`reviewv4.ProblemNode` 和新增 v4 presentation 字段未定义;TypeScript 分支因 `&&` 未运行。 -```text -go test ./internal/reviewv4 ./internal/sessionindex ./internal/inspect ./internal/annotation ./internal/pricing -run 'Test(FrozenInvalidReviewAndLedgerFixturesAreRejected|ParseRejectsFrozenInvalidFixture|ParsersRejectFrozenInvalidFixtures|ParseAndRenderPricingFixtureParity|PricingSupplementFixtureParityAndNullMeansUnknown)$' -count=1 -v -``` +相同命令在实现后 PASS:`internal/conversationchain` 0.428s、`internal/problemmap` 0.555s、`internal/reviewv4` 0.310s;Vitest 1/1 file、57/57 tests。随后增加 canonical digest tamper mirror,最终聚焦合同文件为 58/58 tests。 -结果:PASS;8/8 invalid fixtures 通过生产解析入口返回预期的机器可比较错误码。 - -TypeScript 精确稳定码矩阵: - -```text -cd obsidian-plugin -npx vitest run tests/contracts-v4.test.ts -t 'rejects the frozen .* invalid fixture with its Go-compatible code' -``` +## 完整本地门禁 -结果:PASS;8/8 矩阵测试通过,39 个非矩阵测试按过滤器跳过。同一文件的完整命令 `npx vitest run tests/contracts-v4.test.ts` 也通过 47/47。 +按串行顺序执行: -| 合同 | valid fixture | invalid fixture | Go / TypeScript 预期拒绝码 | -|---|---|---|---| -| review-presentation-v4 | `review-presentation-v4.valid.json` | `review-presentation-v4.invalid.json` | `wire_shape_invalid` | -| machine-ledger-v4 | `machine-ledger-v4.valid.json` | `machine-ledger-v4.invalid.json` | `wire_contract_invalid` | -| session-index-v1 | `session-index-v1.valid.json` | `session-index-v1.invalid.json` | `wire_contract_invalid` | -| session-summary-v1 | `session-summary-v1.valid.json` | `session-summary-v1.invalid.json` | `wire_shape_invalid` | -| session-event-page-v1 | `session-event-page-v1.valid.json` | `session-event-page-v1.invalid.json` | `wire_contract_invalid` | -| agent-annotation-v1 | `agent-annotation-v1.valid.json` | `agent-annotation-v1.invalid.json` | `wire_shape_invalid` | -| pricing-snapshot-v1 | `pricing-snapshot-v1.valid.json` | `pricing-snapshot-v1.invalid.json` | `wire_contract_invalid` | -| pricing-supplement-v1 | `pricing-supplement-v1.valid.json` | `pricing-supplement-v1.invalid.json` | `wire_contract_invalid` | +| 命令 | 结果 | 证据 | +|---|---|---| +| `gofmt -w internal/conversationchain internal/problemmap internal/reviewv4 internal/cli` | PASS | 无输出 | +| `go test -p 1 -timeout 5m -count=1 ./...` | PASS | 57 个 package;较慢 package 包括 `internal/reviewjob` 89.595s、`internal/scan` 79.903s、`test/zerotoken` 62.772s,均低于每个测试二进制 5 分钟超时 | +| `go vet ./...` | PASS | exit 0,无输出 | +| `go mod tidy -diff` | PASS | exit 0,无 diff | +| `cd obsidian-plugin && npm run check` | PASS | lint;17/17 test files、122/122 tests;TypeScript typecheck;production bundle | +| `git diff --check` | PASS | exit 0,无输出 | -拒绝码属于封闭的五类合同:`wire_input_overflow`、`wire_invalid_utf8`、`wire_json_invalid`、`wire_shape_invalid`、`wire_contract_invalid`。Go 和 TypeScript 都保留 cause 链,调用方无需比较可变的人类错误文案。 +独立 ordinary-flow 复核 `go test ./test/zerotoken -count=1 -run 'TestGate(A|B)' -v` PASS:Gate A 154/154 terminal、151 indexed、zero model tokens;Gate B 端到端发布与幂等测试通过。新增 deterministic candidate fixture 明确要求 `agent_run_id=null`,本任务没有启动或实现 Agent 执行。 -## Fixture 字节一致性 +## 十组合同比对 -独立遍历 `testdata/contracts/v4/*.json`,对同名 `obsidian-plugin/tests/fixtures/v4/*.json` 执行 `cmp`。结果为 16/16 字节完全一致。此门禁同时覆盖上表 8 个 valid 和 8 个 invalid fixture。 +`go test ./internal/memory -run TestV4ContractFixtures -count=1 -v` 通过 10/10 命名子测试。Go 与 TypeScript 均使用封闭的五类拒绝码:`wire_input_overflow`、`wire_invalid_utf8`、`wire_json_invalid`、`wire_shape_invalid`、`wire_contract_invalid`。 -## 禁止占位符检查 +| 合同 | invalid fixture 预期码 | +|---|---| +| review-presentation-v4 | `wire_shape_invalid` | +| machine-ledger-v4 | `wire_contract_invalid` | +| session-index-v1 | `wire_contract_invalid` | +| session-summary-v1 | `wire_shape_invalid` | +| session-event-page-v1 | `wire_contract_invalid` | +| agent-annotation-v1 | `wire_shape_invalid` | +| pricing-snapshot-v1 | `wire_contract_invalid` | +| pricing-supplement-v1 | `wire_contract_invalid` | +| conversation-chain-v1 | `wire_contract_invalid` | +| problem-map-candidate-v1 | `wire_contract_invalid` | -按计划原样执行: +独立遍历 `testdata/contracts/v4/*.json` 并对同名插件 fixture 执行 `cmp -s`,结果为 **20/20 Go/plugin fixture files byte-identical**。 -```bash -rg -n $'\x54\x42\x44|\x54\x4f\x44\x4f|\x46\x49\x58\x4d\x45|\x69\x6d\x70\x6c\x65\x6d\x65\x6e\x74\x20\x6c\x61\x74\x65\x72|\x66\x69\x6c\x6c\x20\x69\x6e\x20\x64\x65\x74\x61\x69\x6c\x73|\x68\x61\x6e\x64\x6c\x65\x20\x65\x64\x67\x65\x20\x63\x61\x73\x65\x73|\x73\x69\x6d\x69\x6c\x61\x72\x20\x74\x6f' schemas internal/reviewv4 internal/sessionindex internal/inspect internal/annotation internal/pricing obsidian-plugin/src/contracts/review-v4.ts -``` +## 扩展合同与迁移边界 -结果:无输出,`rg` exit 1,表示要求的路径内无命中。 +- 会话身份始终为 `(provider, session_id)`;conversation chain 只允许 user/assistant 可见 excerpt,4,096 UTF-8 bytes 上限,并绑定认证 source refs。 +- 两个新增自摘要合同只省略各自的 `digest` 字段计算 canonical digest;valid fixtures 使用非零 digest,tamper tests 同时覆盖 Go/TypeScript。 +- 正式问题图只存在于 `review-presentation-v4`;验证 parent/relation 存在、无环、每组 sibling order 唯一稳定、related/alternate 最多两个。 +- 缺失结论必须为空文本并携带 typed reason;milestone annotation 使用通用 `confirmed_entity_id`、source-turn dependency,禁止 decision-only fields。 +- v4/partial 兼容 fixture 仅增加空问题图、空 chain dependencies 和 neutral closed-loop 默认;对应 review hash 与 ledger self hash 已机械重算。v3 语义未改。 +- 64 KiB per-source ceiling 仅冻结为 CLI 常量与显式 truncation coverage 合同;未实现 SourceAdapter 读取行为。 ## Windows 证据状态 -`.github/workflows/ci.yml` 的 `test` job 包含: - -```text -name: windows-x64 -os: windows-latest -``` - -该原生 job 会执行 Obsidian 门禁、`go test ./...`、Windows 替换压测、race/vet、PowerShell 可重现发行包与插件打包检查。当前实现提交未推送且无 PR,因此状态为 **PENDING**。 - -补充交叉编译:使用 `CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go test -c` 分别编译 `memory`、`reviewv4`、`sessionindex`、`inspect`、`annotation`、`pricing`、`migrationv4`、`publication`、`syncproject`和 `cli`,10/10 通过。该结果只证明 Windows amd64 可编译,不是 Windows 原生执行证据。 +`.github/workflows/ci.yml` 中的 `windows-x64` / `windows-latest` 原生 job 尚未针对实现提交运行。没有 push、PR 或原生 Windows 结果,因此 Gate 0 状态不得升级为跨平台完成。 ## 明确不属于 Gate 0 的后续工作 -- Session index 累积发布与受限查询; -- Obsidian “全部 Sessions”浏览器、虚拟列表与真实 Vault 验收; -- 人工决策/约定、AI 候选提炼、CAS 确认与发布; -- ModelPriceWatch 缓存、匹配、不可变价格快照、补价和用量卡片。 - -上述四组仍需按各自实施计划完成,不因本地 Gate 0 验收而视为已实现。 +- conversation-chain segmentation 或 SourceAdapter 读取; +- problem placement service/store; +- Agent 执行; +- Obsidian UI 和真实 Vault 验收。 From b30876db1d61026eb52f5d6d6533c052fd7a93b7 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 01:42:04 +0800 Subject: [PATCH 18/25] fix: align v4 problem contract boundaries --- internal/cli/contracts.go | 70 +++++++++++++++++--- internal/cli/contracts_test.go | 22 +++++- internal/conversationchain/codec.go | 5 +- internal/conversationchain/codec_test.go | 32 +++++++++ internal/conversationchain/types.go | 2 + internal/conversationchain/validate.go | 33 +++++++-- internal/memory/api_compat_test.go | 32 +++++++++ internal/problemmap/candidate_codec.go | 5 +- internal/problemmap/types.go | 2 + internal/problemmap/validate.go | 2 +- internal/problemmap/validate_test.go | 27 ++++++++ internal/reviewv4/codec_test.go | 22 ++++++ internal/reviewv4/validate.go | 8 ++- obsidian-plugin/src/data/contracts-v4.ts | 7 +- obsidian-plugin/tests/contracts-v4.test.ts | 32 +++++++++ schemas/conversation-chain-v1.schema.json | 6 +- schemas/problem-map-candidate-v1.schema.json | 2 +- schemas/review-presentation-v4.schema.json | 9 ++- 18 files changed, 279 insertions(+), 39 deletions(-) diff --git a/internal/cli/contracts.go b/internal/cli/contracts.go index b5d1b15..67d73c6 100644 --- a/internal/cli/contracts.go +++ b/internal/cli/contracts.go @@ -7,15 +7,18 @@ import ( "strings" "time" "unicode/utf8" + + "github.com/neomei/SessionReviewer/internal/strictjson" ) const ( - MaxInspectPageSize = 100 - MaxInspectQueryBytes = 256 - MaxDecisionInputBytes = 64 << 10 - MaxOpaqueCursorBytes = 4096 - MaxInspectResponseBytes = 1 << 20 - MaxConversationSourceReadBytes = 64 << 10 + MaxInspectPageSize = 100 + MaxInspectQueryBytes = 256 + MaxDecisionInputBytes = 64 << 10 + MaxOpaqueCursorBytes = 4096 + MaxInspectResponseBytes = 1 << 20 + MaxConversationSourceReadBytes = 64 << 10 + MaxJSONSafeInteger int64 = 1<<53 - 1 ) const InspectExecutionTimeout = 5 * time.Second @@ -102,6 +105,12 @@ type ProblemRequest struct { ProblemID string NewParentID string ParentID string + OrderedChildIDs []string +} + +type problemReorderInput struct { + SchemaVersion int `json:"schema_version" required:"true"` + OrderedChildIDs []string `json:"ordered_child_ids" required:"true"` } type DecisionRequest struct { @@ -231,12 +240,23 @@ func parsePositiveInt(value string) (int, bool) { func requirePositiveInt(value string) (int, error) { n, ok := parsePositiveInt(value) - if !ok { + if !ok || int64(n) > MaxJSONSafeInteger { return 0, contractError("integer must be a positive decimal number") } return n, nil } +func requireNonnegativeInt(value string) (int, error) { + if value == "0" { + return 0, nil + } + n, err := requirePositiveInt(value) + if err != nil { + return 0, contractError("integer must be a nonnegative decimal number") + } + return n, nil +} + func requirePageLimit(value string) (int, error) { n, err := requirePositiveInt(value) if err != nil || n > MaxInspectPageSize { @@ -412,7 +432,7 @@ func ParseProblemContract(args []string) (ProblemRequest, error) { case "move": return parseProblemMove(args[1:]) case "reorder": - return parseProblemReorder(args[1:]) + return ProblemRequest{}, contractError("problem reorder requires a versioned stdin payload") default: return ProblemRequest{}, contractError("unknown problems command") } @@ -441,7 +461,7 @@ func parseProblemTransition(args []string) (ProblemRequest, error) { if err != nil { return ProblemRequest{}, err } - mapRevision, err := requirePositiveInt(flags.values["expected-problem-map-revision"]) + mapRevision, err := requireNonnegativeInt(flags.values["expected-problem-map-revision"]) if err != nil { return ProblemRequest{}, err } @@ -475,7 +495,7 @@ func parseProblemMove(args []string) (ProblemRequest, error) { if err = requireSafeIDs(flags, "project-id", "problem-id", "new-parent-id"); err != nil { return ProblemRequest{}, err } - revision, err := requirePositiveInt(flags.values["expected-problem-map-revision"]) + revision, err := requireNonnegativeInt(flags.values["expected-problem-map-revision"]) if err != nil { return ProblemRequest{}, err } @@ -496,7 +516,7 @@ func parseProblemReorder(args []string) (ProblemRequest, error) { if err = requireSafeIDs(flags, "project-id", "parent-id"); err != nil { return ProblemRequest{}, err } - revision, err := requirePositiveInt(flags.values["expected-problem-map-revision"]) + revision, err := requireNonnegativeInt(flags.values["expected-problem-map-revision"]) if err != nil { return ProblemRequest{}, err } @@ -506,6 +526,34 @@ func parseProblemReorder(args []string) (ProblemRequest, error) { return ProblemRequest{Command: "reorder", ProjectID: flags.values["project-id"], ParentID: flags.values["parent-id"], ExpectedProblemMapRevision: revision, ExpectedReviewSHA256: flags.values["expected-review-sha256"]}, nil } +// ParseProblemContractWithInput parses the only problem command with a stdin +// body. The v1 payload is bounded, exact, and carries the complete desired +// direct-child order; argv never accepts a path or arbitrary input flag. +func ParseProblemContractWithInput(args []string, input []byte, currentDirectChildIDs []string) (ProblemRequest, error) { + if len(args) == 0 || args[0] != "reorder" { + return ProblemRequest{}, contractError("versioned stdin is only valid for problem reorder") + } + if len(input) > MaxDecisionInputBytes { + return ProblemRequest{}, contractError("problem reorder stdin exceeds its byte limit") + } + request, err := parseProblemReorder(args[1:]) + if err != nil { + return ProblemRequest{}, err + } + var payload problemReorderInput + if err := strictjson.Decode(input, &payload); err != nil { + return ProblemRequest{}, contractError("problem reorder stdin is not valid versioned JSON") + } + if payload.SchemaVersion != 1 || payload.OrderedChildIDs == nil { + return ProblemRequest{}, contractError("problem reorder stdin must be schema version 1 with an ordered child array") + } + if err := ValidateCompleteSiblingOrder(currentDirectChildIDs, payload.OrderedChildIDs); err != nil { + return ProblemRequest{}, err + } + request.OrderedChildIDs = append([]string{}, payload.OrderedChildIDs...) + return request, nil +} + func ValidateCompleteSiblingOrder(current, ordered []string) error { if len(current) != len(ordered) { return contractError("sibling order must include every direct child exactly once") diff --git a/internal/cli/contracts_test.go b/internal/cli/contracts_test.go index fa20688..cd2e3d1 100644 --- a/internal/cli/contracts_test.go +++ b/internal/cli/contracts_test.go @@ -598,6 +598,10 @@ func TestProblemContractsEnforceTargetAndCASGrammar(t *testing.T) { t.Fatalf("targetless action %q rejected: %v", action, err) } } + initialApply := replaceContractFlagValue(t, withTarget, "--expected-problem-map-revision", "0") + if request, err := ParseProblemContract(initialApply); err != nil || request.ExpectedProblemMapRevision != 0 { + t.Fatalf("initial-map apply = %+v err=%v", request, err) + } } func TestProblemMoveAndReorderRequireCompleteSiblingSet(t *testing.T) { @@ -605,10 +609,24 @@ func TestProblemMoveAndReorderRequireCompleteSiblingSet(t *testing.T) { if err != nil || move.NewParentID != "root" { t.Fatalf("move = %+v err=%v", move, err) } - reorder, err := ParseProblemContract([]string{"reorder", "--project-id", "p", "--parent-id", "root", "--expected-problem-map-revision", "2", "--expected-review-sha256", contractTestSHA, "--json"}) - if err != nil || reorder.ParentID != "root" { + reorderArgs := []string{"reorder", "--project-id", "p", "--parent-id", "root", "--expected-problem-map-revision", "2", "--expected-review-sha256", contractTestSHA, "--json"} + if _, err := ParseProblemContract(reorderArgs); err == nil { + t.Fatal("accepted reorder without its versioned stdin payload") + } + payload := []byte(`{"schema_version":1,"ordered_child_ids":["b","a"]}`) + reorder, err := ParseProblemContractWithInput(reorderArgs, payload, []string{"a", "b"}) + if err != nil || reorder.ParentID != "root" || !reflect.DeepEqual(reorder.OrderedChildIDs, []string{"b", "a"}) { t.Fatalf("reorder = %+v err=%v", reorder, err) } + if _, err := ParseProblemContractWithInput(reorderArgs, []byte(`{"schema_version":1,"ordered_child_ids":["a"]}`), []string{"a", "b"}); err == nil { + t.Fatal("accepted an incomplete parsed sibling order") + } + if _, err := ParseProblemContractWithInput(reorderArgs, []byte(`{"schema_version":1,"ordered_child_ids":["b","a"],"file":"x"}`), []string{"a", "b"}); err == nil { + t.Fatal("accepted an arbitrary input field") + } + if _, err := ParseProblemContractWithInput(reorderArgs, []byte(strings.Repeat(" ", MaxDecisionInputBytes+1)), []string{"a", "b"}); err == nil { + t.Fatal("accepted an oversized reorder payload") + } for _, ordered := range [][]string{{"a"}, {"a", "a"}, {"a", "foreign", "b"}} { if err := ValidateCompleteSiblingOrder([]string{"a", "b"}, ordered); err == nil { t.Fatalf("accepted incomplete or foreign sibling order: %#v", ordered) diff --git a/internal/conversationchain/codec.go b/internal/conversationchain/codec.go index a7baffc..1de44e5 100644 --- a/internal/conversationchain/codec.go +++ b/internal/conversationchain/codec.go @@ -19,7 +19,7 @@ func Parse(data []byte) (Document, error) { if err := Validate(document); err != nil { return document, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } - if !isZeroDigest(document.Digest) && CanonicalDigest(document) != document.Digest { + if CanonicalDigest(document) != document.Digest { return document, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("conversation chain digest mismatch")) } return document, nil @@ -85,5 +85,4 @@ func normalize(document *Document) { } } -func zeroDigest() string { return "sha256:" + strings.Repeat("0", 64) } -func isZeroDigest(value string) bool { return value == zeroDigest() } +func zeroDigest() string { return "sha256:" + strings.Repeat("0", 64) } diff --git a/internal/conversationchain/codec_test.go b/internal/conversationchain/codec_test.go index 833a2b7..4ac260e 100644 --- a/internal/conversationchain/codec_test.go +++ b/internal/conversationchain/codec_test.go @@ -61,6 +61,38 @@ func TestRenderConversationChainNormalizesCollectionsAndBindsDigest(t *testing.T } } +func TestParseConversationChainRejectsZeroDigest(t *testing.T) { + fixture, err := os.ReadFile("../../testdata/contracts/v4/conversation-chain-v1.valid.json") + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(fixture, &raw); err != nil { + t.Fatal(err) + } + raw["digest"] = "sha256:" + strings.Repeat("0", 64) + body, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if _, err := Parse(body); err == nil { + t.Fatal("accepted an unbound all-zero persisted digest") + } +} + +func TestConversationChainRejectsIntegersAboveJavaScriptSafeMaximum(t *testing.T) { + document := frozenChain() + document.TurnUnits[0].UserMessage.SourceRef.RecordOrdinal = 1 << 53 + if err := Validate(document); err == nil { + t.Fatal("accepted record ordinal above JavaScript safe integer maximum") + } + document = frozenChain() + document.Coverage.SourceMessages = 1 << 53 + if err := Validate(document); err == nil { + t.Fatal("accepted coverage count above JavaScript safe integer maximum") + } +} + func TestConversationChainRejectsOversizedVisibleExcerptAndUnauthenticatedSource(t *testing.T) { document := frozenChain() document.TurnUnits[0].UserMessage.VisibleExcerpt = strings.Repeat("界", 1366) diff --git a/internal/conversationchain/types.go b/internal/conversationchain/types.go index 7410efc..b007af1 100644 --- a/internal/conversationchain/types.go +++ b/internal/conversationchain/types.go @@ -1,5 +1,7 @@ package conversationchain +const MaxWireInteger uint64 = 1<<53 - 1 + type Role string const ( diff --git a/internal/conversationchain/validate.go b/internal/conversationchain/validate.go index 4940a8d..6b41136 100644 --- a/internal/conversationchain/validate.go +++ b/internal/conversationchain/validate.go @@ -25,6 +25,9 @@ func Validate(document Document) error { if len(document.TurnUnits) > 65536 { return errors.New("conversation chain exceeds turn limit") } + if document.Coverage.SourceMessages > MaxWireInteger || document.Coverage.CapturedMessages > MaxWireInteger || document.Coverage.TurnUnits > MaxWireInteger || document.Coverage.UnansweredUnits > MaxWireInteger || document.Coverage.TruncatedMessages > MaxWireInteger { + return errors.New("conversation chain coverage exceeds the wire integer maximum") + } var captured, unanswered, truncated uint64 turnIDs := make(map[string]bool, len(document.TurnUnits)) for index, turn := range document.TurnUnits { @@ -38,17 +41,25 @@ func Validate(document Document) error { if err := validateMessage(document, turn.UserMessage, RoleUser); err != nil { return fmt.Errorf("turn unit %q user message: %w", turn.TurnUnitID, err) } - captured++ + if !incrementWireCount(&captured) { + return errors.New("captured message count exceeds the wire integer maximum") + } if turn.UserMessage.Truncated { - truncated++ + if !incrementWireCount(&truncated) { + return errors.New("truncated message count exceeds the wire integer maximum") + } } for _, message := range turn.AssistantMessages { if err := validateMessage(document, message, RoleAssistant); err != nil { return fmt.Errorf("turn unit %q assistant message: %w", turn.TurnUnitID, err) } - captured++ + if !incrementWireCount(&captured) { + return errors.New("captured message count exceeds the wire integer maximum") + } if message.Truncated { - truncated++ + if !incrementWireCount(&truncated) { + return errors.New("truncated message count exceeds the wire integer maximum") + } } } for _, action := range turn.Actions { @@ -77,7 +88,9 @@ func Validate(document Document) error { if len(turn.AssistantMessages) != 0 { return fmt.Errorf("turn unit %q claims no answer but has assistant messages", turn.TurnUnitID) } - unanswered++ + if !incrementWireCount(&unanswered) { + return errors.New("unanswered unit count exceeds the wire integer maximum") + } case AnswerAnswered, AnswerPartial: if len(turn.AssistantMessages) == 0 { return fmt.Errorf("turn unit %q claims an answer without assistant messages", turn.TurnUnitID) @@ -100,10 +113,18 @@ func validateMessage(document Document, message Message, expected Role) error { } func validateSourceRef(document Document, ref SourceRef) error { - if ref.Provider != document.Provider || ref.SessionID != document.SessionID || !validID(ref.Provider) || !validID(ref.SessionID) || !validID(ref.SourceIdentity) || !shaPattern.MatchString(ref.SourceHash) { + if ref.Provider != document.Provider || ref.SessionID != document.SessionID || !validID(ref.Provider) || !validID(ref.SessionID) || !validID(ref.SourceIdentity) || ref.RecordOrdinal > MaxWireInteger || !shaPattern.MatchString(ref.SourceHash) { return errors.New("source reference is not authenticated to the conversation identity") } return nil } +func incrementWireCount(value *uint64) bool { + if *value == MaxWireInteger { + return false + } + *value++ + return true +} + func validTimestamp(value string) bool { return value != "" && validText(value, 128) } diff --git a/internal/memory/api_compat_test.go b/internal/memory/api_compat_test.go index d69a870..af66577 100644 --- a/internal/memory/api_compat_test.go +++ b/internal/memory/api_compat_test.go @@ -84,6 +84,35 @@ func TestV4ContractFixtures(t *testing.T) { } } +func TestExpandedV4SchemasEnforceRevisionAndSafeIntegerBoundaries(t *testing.T) { + reviewSchema := readContractJSON(t, filepath.Join("..", "..", "schemas", "review-presentation-v4.schema.json")) + review := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", "review-presentation-v4.valid.json")).(map[string]any) + review["problem_nodes"] = []any{map[string]any{ + "id": "problem-1", "question": "Why?", "primary_parent_id": nil, "related_node_ids": []any{}, + "workflow_state": "not_started", "answer_state": "no_answer", "completion_criterion": "", "current_conclusion": "", + "source_turn_refs": []any{}, "provenance": "human_created", "first_proposed_at": "2026-09-04T00:00:00Z", + "sibling_order": json.Number("0"), "confirmed_at": nil, "revision": json.Number("1"), + }} + review["problem_root_ids"] = []any{"problem-1"} + if err := validateContractSchema(reviewSchema, review, "$", reviewSchema); err == nil { + t.Fatal("review schema accepted a non-empty problem map at revision zero") + } + + chainSchema := readContractJSON(t, filepath.Join("..", "..", "schemas", "conversation-chain-v1.schema.json")) + chain := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", "conversation-chain-v1.valid.json")).(map[string]any) + chain["coverage"].(map[string]any)["source_messages"] = json.Number("9007199254740992") + if err := validateContractSchema(chainSchema, chain, "$", chainSchema); err == nil { + t.Fatal("conversation schema accepted an integer above the JavaScript safe maximum") + } + + candidateSchema := readContractJSON(t, filepath.Join("..", "..", "schemas", "problem-map-candidate-v1.schema.json")) + candidates := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", "problem-map-candidate-v1.valid.json")).(map[string]any) + candidates["candidates"].([]any)[0].(map[string]any)["revision"] = json.Number("9007199254740992") + if err := validateContractSchema(candidateSchema, candidates, "$", candidateSchema); err == nil { + t.Fatal("candidate schema accepted a revision above the JavaScript safe maximum") + } +} + func TestV4ContractFixtureDecoderRejectsUnsafeBoundaries(t *testing.T) { cases := []struct { name string @@ -419,6 +448,9 @@ func validateContractSchema(schema, value any, path string, root any) error { } } if array, ok := value.([]any); ok { + if min, ok := s["minItems"].(json.Number); ok && len(array) < int(numberInt(min)) { + return fmt.Errorf("%s: too few items", path) + } if max, ok := s["maxItems"].(json.Number); ok && len(array) > int(numberInt(max)) { return fmt.Errorf("%s: too many items", path) } diff --git a/internal/problemmap/candidate_codec.go b/internal/problemmap/candidate_codec.go index 29e4b65..df7e7c8 100644 --- a/internal/problemmap/candidate_codec.go +++ b/internal/problemmap/candidate_codec.go @@ -20,7 +20,7 @@ func ParseCandidates(data []byte) (CandidateStore, error) { if err := ValidateCandidates(store); err != nil { return store, strictjson.NewRejection(strictjson.CodeContractInvalid, err) } - if !isZeroDigest(store.Digest) && CanonicalDigest(store) != store.Digest { + if CanonicalDigest(store) != store.Digest { return store, strictjson.NewRejection(strictjson.CodeContractInvalid, errors.New("problem candidate store digest mismatch")) } return store, nil @@ -91,5 +91,4 @@ func normalizeCandidates(store *CandidateStore) { } } -func zeroDigest() string { return "sha256:" + strings.Repeat("0", 64) } -func isZeroDigest(value string) bool { return value == zeroDigest() } +func zeroDigest() string { return "sha256:" + strings.Repeat("0", 64) } diff --git a/internal/problemmap/types.go b/internal/problemmap/types.go index 578c4d6..d81b8c9 100644 --- a/internal/problemmap/types.go +++ b/internal/problemmap/types.go @@ -2,6 +2,8 @@ package problemmap import "github.com/neomei/SessionReviewer/internal/reviewv4" +const MaxWireInteger int64 = 1<<53 - 1 + type Relation string const ( diff --git a/internal/problemmap/validate.go b/internal/problemmap/validate.go index ead0a39..27dfa4f 100644 --- a/internal/problemmap/validate.go +++ b/internal/problemmap/validate.go @@ -29,7 +29,7 @@ func ValidateCandidates(store CandidateStore) error { } seen := make(map[string]bool, len(store.Candidates)) for index, candidate := range store.Candidates { - if !validID(candidate.CandidateID) || seen[candidate.CandidateID] || candidate.ProjectID != store.ProjectID || candidate.Question == "" || !validText(candidate.Question, 4096) || len(candidate.SourceTurnRefs) == 0 || len(candidate.SourceTurnRefs) > 256 || len(candidate.AlternateTargetIDs) > 2 || len(candidate.RelatedNodeIDs) > 2 || len(candidate.Grounds) > 256 || len(candidate.DependencyDigests) == 0 || len(candidate.DependencyDigests) > 256 || candidate.Revision < 1 || candidate.CreatedAt == "" || !validText(candidate.CreatedAt, 128) || candidate.UpdatedAt == "" || !validText(candidate.UpdatedAt, 128) { + if !validID(candidate.CandidateID) || seen[candidate.CandidateID] || candidate.ProjectID != store.ProjectID || candidate.Question == "" || !validText(candidate.Question, 4096) || len(candidate.SourceTurnRefs) == 0 || len(candidate.SourceTurnRefs) > 256 || len(candidate.AlternateTargetIDs) > 2 || len(candidate.RelatedNodeIDs) > 2 || len(candidate.Grounds) > 256 || len(candidate.DependencyDigests) == 0 || len(candidate.DependencyDigests) > 256 || candidate.Revision < 1 || int64(candidate.Revision) > MaxWireInteger || candidate.CreatedAt == "" || !validText(candidate.CreatedAt, 128) || candidate.UpdatedAt == "" || !validText(candidate.UpdatedAt, 128) { return fmt.Errorf("invalid or duplicate problem candidate %d", index) } seen[candidate.CandidateID] = true diff --git a/internal/problemmap/validate_test.go b/internal/problemmap/validate_test.go index 1b2e3bd..367943f 100644 --- a/internal/problemmap/validate_test.go +++ b/internal/problemmap/validate_test.go @@ -75,6 +75,33 @@ func TestRenderProblemCandidatesNormalizesCollectionsAndBindsDigest(t *testing.T } } +func TestParseProblemCandidatesRejectsZeroDigest(t *testing.T) { + fixture, err := os.ReadFile("../../testdata/contracts/v4/problem-map-candidate-v1.valid.json") + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(fixture, &raw); err != nil { + t.Fatal(err) + } + raw["digest"] = "sha256:" + strings.Repeat("0", 64) + body, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if _, err := ParseCandidates(body); err == nil { + t.Fatal("accepted an unbound all-zero persisted digest") + } +} + +func TestProblemCandidatesRejectRevisionAboveJavaScriptSafeMaximum(t *testing.T) { + store := frozenCandidates() + store.Candidates[0].Revision = 1 << 53 + if err := ValidateCandidates(store); err == nil { + t.Fatal("accepted candidate revision above JavaScript safe integer maximum") + } +} + func TestProblemGraphRejectsCycle(t *testing.T) { nodes := []reviewv4.ProblemNode{ {ID: "p-a", PrimaryParentID: stringPtr("p-b")}, diff --git a/internal/reviewv4/codec_test.go b/internal/reviewv4/codec_test.go index 7208a52..ec9a0a0 100644 --- a/internal/reviewv4/codec_test.go +++ b/internal/reviewv4/codec_test.go @@ -376,6 +376,28 @@ func TestValidatePresentationRequiresCanonicalProblemRootOrder(t *testing.T) { } } +func TestValidatePresentationProblemMapRevisionAndSafeIntegerBoundary(t *testing.T) { + presentation := minimumPresentation() + if err := ValidatePresentation(presentation); err != nil { + t.Fatalf("empty problem map at revision zero rejected: %v", err) + } + presentation.ProblemMapRevision = 1 << 53 + if err := ValidatePresentation(presentation); err == nil { + t.Fatal("accepted problem map revision above JavaScript safe integer maximum") + } + presentation = minimumPresentation() + presentation.ProblemMapRevision = 1 + presentation.ProblemNodes = []ProblemNode{{ + ID: "problem-1", Question: "Why?", RelatedNodeIDs: []string{}, WorkflowState: "not_started", AnswerState: "no_answer", + SourceTurnRefs: []SourceTurnRef{}, Provenance: "human_created", FirstProposedAt: "2026-09-04T00:00:00Z", SiblingOrder: 0, Revision: 1, + }} + presentation.ProblemRootIDs = []string{"problem-1"} + presentation.ProblemMapRevision = 0 + if err := ValidatePresentation(presentation); err == nil { + t.Fatal("accepted non-empty problem map at revision zero") + } +} + func TestValidateLedgerUsesOnlyCurrentPricingForAggregateCompleteness(t *testing.T) { ledger := frozenLedger(t) historical := ledger.PricingSnapshots[0] diff --git a/internal/reviewv4/validate.go b/internal/reviewv4/validate.go index 29c306e..f7bad42 100644 --- a/internal/reviewv4/validate.go +++ b/internal/reviewv4/validate.go @@ -15,6 +15,8 @@ var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) var shaRE = regexp.MustCompile(`^[0-9a-f]{64}$`) +const maxWireInteger int64 = 1<<53 - 1 + func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } func text(value string, maximum int) bool { return len(value) <= maximum } func optionalText(value *string, maximum int) bool { return value == nil || text(*value, maximum) } @@ -156,7 +158,7 @@ func ValidatePresentation(p Presentation) error { } loopIDs[loop.ID] = true } - if p.ProblemMapRevision < 0 || (len(p.ProblemNodes) > 0 && p.ProblemMapRevision < 1) { + if p.ProblemMapRevision < 0 || int64(p.ProblemMapRevision) > maxWireInteger || (len(p.ProblemNodes) > 0 && p.ProblemMapRevision < 1) { return errors.New("invalid problem map revision") } if err := ValidateProblemGraph(p.ProblemNodes); err != nil { @@ -218,7 +220,7 @@ func validateClosedLoop(loop ClosedLoop, chainTurns map[string]bool) error { return fmt.Errorf("%s: %w", name, err) } } - if len(loop.SourceTurnRefs) > 256 || loop.Coverage.CapturedTurns != uint64(len(loop.SourceTurnRefs)) || loop.Coverage.SourceTurns < loop.Coverage.CapturedTurns || loop.Coverage.TruncatedTurns+loop.Coverage.SourceUnavailableTurns > loop.Coverage.SourceTurns { + if len(loop.SourceTurnRefs) > 256 || loop.Coverage.SourceTurns > uint64(maxWireInteger) || loop.Coverage.CapturedTurns > uint64(maxWireInteger) || loop.Coverage.TruncatedTurns > uint64(maxWireInteger) || loop.Coverage.SourceUnavailableTurns > uint64(maxWireInteger) || loop.Coverage.CapturedTurns != uint64(len(loop.SourceTurnRefs)) || loop.Coverage.SourceTurns < loop.Coverage.CapturedTurns || loop.Coverage.SourceUnavailableTurns > loop.Coverage.SourceTurns || loop.Coverage.TruncatedTurns > loop.Coverage.SourceTurns-loop.Coverage.SourceUnavailableTurns { return errors.New("closed-loop coverage does not reconcile") } if err := validateSourceTurnRefs(loop.SourceTurnRefs, chainTurns); err != nil { @@ -331,7 +333,7 @@ func sourceTurnKey(ref SourceTurnRef) string { func ValidateProblemGraph(nodes []ProblemNode) error { byID := make(map[string]ProblemNode, len(nodes)) for _, node := range nodes { - if !validID(node.ID) || node.Question == "" || !text(node.Question, 4096) || node.SiblingOrder < 0 || node.Revision < 1 || !text(node.CompletionCriterion, 16384) || !text(node.CurrentConclusion, 16384) || node.FirstProposedAt == "" || !text(node.FirstProposedAt, 128) || !optionalText(node.ConfirmedAt, 128) || len(node.RelatedNodeIDs) > 2 || len(node.SourceTurnRefs) > 256 { + if !validID(node.ID) || node.Question == "" || !text(node.Question, 4096) || node.SiblingOrder < 0 || int64(node.SiblingOrder) > maxWireInteger || node.Revision < 1 || int64(node.Revision) > maxWireInteger || !text(node.CompletionCriterion, 16384) || !text(node.CurrentConclusion, 16384) || node.FirstProposedAt == "" || !text(node.FirstProposedAt, 128) || !optionalText(node.ConfirmedAt, 128) || len(node.RelatedNodeIDs) > 2 || len(node.SourceTurnRefs) > 256 { return fmt.Errorf("invalid problem node %q", node.ID) } if _, exists := byID[node.ID]; exists { diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index 0690bcc..64d770d 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -157,10 +157,11 @@ function parseReviewPresentationDocument(source: string): ReviewPresentationV4 { parseUniqueEntityArray(row.open_loops, "$.open_loops", 65536, ["id", "title", "status", "question", "next_experiment", "completion_criterion"], ["title", "status", "question", "next_experiment", "completion_criterion"]); - integer(row.problem_map_revision, "$.problem_map_revision"); + const problemMapRevision = integer(row.problem_map_revision, "$.problem_map_revision"); const rootIDs = idArray(row.problem_root_ids, "$.problem_root_ids", 65536, true); const nodes = boundedArray(row.problem_nodes, "$.problem_nodes", 65536) .map((node, index) => parseProblemNode(node, `$.problem_nodes[${index}]`)); + if (nodes.length > 0 && problemMapRevision === 0) throw new Error("problem_map_revision must be positive when problem_nodes is non-empty"); assertProblemGraphCore(nodes, rootIDs); const dependencies = boundedArray(row.chain_dependencies, "$.chain_dependencies", 65536) .map((dependency, index) => parseChainDependency(dependency, `$.chain_dependencies[${index}]`)); @@ -403,7 +404,7 @@ export function parseConversationChainV1(source: string): ConversationChainV1 { throw new Error("conversation chain coverage does not reconcile"); } const result = row as unknown as ConversationChainV1; - if (claimedDigest !== ZERO_DIGEST && canonicalConversationChainDigest(result) !== claimedDigest) { + if (canonicalConversationChainDigest(result) !== claimedDigest) { throw new Error("conversation chain digest mismatch"); } return result; @@ -469,7 +470,7 @@ export function parseProblemMapCandidateV1(source: string): ProblemMapCandidateV text(candidate.updated_at, `${path}.updated_at`, 128, true); } const result = row as unknown as ProblemMapCandidateV1; - if (claimedDigest !== ZERO_DIGEST && canonicalProblemMapCandidateDigest(result) !== claimedDigest) { + if (canonicalProblemMapCandidateDigest(result) !== claimedDigest) { throw new Error("problem map candidate digest mismatch"); } return result; diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index 741baa3..7cab16d 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -370,9 +370,41 @@ describe("conversation chain and problem map contracts", () => { chain.segmentation_rule_version = "visible-turn-v2"; expect(() => parseConversationChainV1(JSON.stringify(chain))).toThrow(/digest/i); + const zeroChain = await fixtureObject("conversation-chain-v1.valid.json"); + zeroChain.digest = `sha256:${"0".repeat(64)}`; + expect(() => parseConversationChainV1(JSON.stringify(zeroChain))).toThrow(/digest/i); + const candidates = await fixtureObject("problem-map-candidate-v1.valid.json") as { candidates: JsonObject[] }; candidates.candidates[0].question = "Tampered question?"; expect(() => parseProblemMapCandidateV1(JSON.stringify(candidates))).toThrow(/digest/i); + + const zeroCandidates = await fixtureObject("problem-map-candidate-v1.valid.json"); + zeroCandidates.digest = `sha256:${"0".repeat(64)}`; + expect(() => parseProblemMapCandidateV1(JSON.stringify(zeroCandidates))).toThrow(/digest/i); + }); + + it("keeps revision-zero and integer maxima identical across v4 parsers", async () => { + const review = await fixtureObject("review-presentation-v4.valid.json") as { + problem_map_revision: number; + problem_root_ids: string[]; + problem_nodes: JsonObject[]; + }; + review.problem_nodes = [{ + id: "problem-1", question: "Why?", primary_parent_id: null, related_node_ids: [], + workflow_state: "not_started", answer_state: "no_answer", completion_criterion: "", + current_conclusion: "", source_turn_refs: [], provenance: "human_created", + first_proposed_at: "2026-09-04T00:00:00Z", sibling_order: 0, confirmed_at: null, revision: 1 + }]; + review.problem_root_ids = ["problem-1"]; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/revision|zero|positive/i); + + const chain = await fixtureObject("conversation-chain-v1.valid.json") as { coverage: JsonObject }; + chain.coverage.source_messages = Number.MAX_SAFE_INTEGER + 1; + expect(() => parseConversationChainV1(JSON.stringify(chain))).toThrow(/integer|safe/i); + + const candidates = await fixtureObject("problem-map-candidate-v1.valid.json") as { candidates: JsonObject[] }; + candidates.candidates[0].revision = Number.MAX_SAFE_INTEGER + 1; + expect(() => parseProblemMapCandidateV1(JSON.stringify(candidates))).toThrow(/integer|safe/i); }); it("enforces formal problem graph cycles, relations, and sibling order", () => { diff --git a/schemas/conversation-chain-v1.schema.json b/schemas/conversation-chain-v1.schema.json index 4bffa61..c528169 100644 --- a/schemas/conversation-chain-v1.schema.json +++ b/schemas/conversation-chain-v1.schema.json @@ -18,7 +18,7 @@ "source_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "source_identity", "record_ordinal", "source_hash"], - "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "source_identity": { "$ref": "#/$defs/id" }, "record_ordinal": { "type": "integer", "minimum": 0 }, "source_hash": { "$ref": "#/$defs/sha256" } } + "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "source_identity": { "$ref": "#/$defs/id" }, "record_ordinal": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "source_hash": { "$ref": "#/$defs/sha256" } } }, "message": { "type": "object", "additionalProperties": false, @@ -39,7 +39,7 @@ "type": "object", "additionalProperties": false, "required": ["turn_unit_id", "ordinal", "started_at", "ended_at", "user_message", "assistant_messages", "actions", "results", "answer_state"], "properties": { - "turn_unit_id": { "$ref": "#/$defs/id" }, "ordinal": { "type": "integer", "minimum": 1 }, "started_at": { "$ref": "#/$defs/timestamp" }, + "turn_unit_id": { "$ref": "#/$defs/id" }, "ordinal": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "started_at": { "$ref": "#/$defs/timestamp" }, "ended_at": { "type": ["string", "null"], "minLength": 1, "maxLength": 128 }, "user_message": { "$ref": "#/$defs/message" }, "assistant_messages": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/message" } }, "actions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/action" } }, @@ -50,7 +50,7 @@ "coverage": { "type": "object", "additionalProperties": false, "required": ["source_messages", "captured_messages", "turn_units", "unanswered_units", "truncated_messages"], - "properties": { "source_messages": { "type": "integer", "minimum": 0 }, "captured_messages": { "type": "integer", "minimum": 0 }, "turn_units": { "type": "integer", "minimum": 0 }, "unanswered_units": { "type": "integer", "minimum": 0 }, "truncated_messages": { "type": "integer", "minimum": 0 } } + "properties": { "source_messages": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "captured_messages": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "turn_units": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "unanswered_units": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "truncated_messages": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } } } } } diff --git a/schemas/problem-map-candidate-v1.schema.json b/schemas/problem-map-candidate-v1.schema.json index 462d83b..f6bbdb3 100644 --- a/schemas/problem-map-candidate-v1.schema.json +++ b/schemas/problem-map-candidate-v1.schema.json @@ -28,7 +28,7 @@ "dependency_digests": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/digest" } }, "analysis_mode": { "enum": ["deterministic", "agent_requested"] }, "agent_run_id": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, - "revision": { "type": "integer", "minimum": 1 }, "created_at": { "$ref": "#/$defs/timestamp" }, "updated_at": { "$ref": "#/$defs/timestamp" } + "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "created_at": { "$ref": "#/$defs/timestamp" }, "updated_at": { "$ref": "#/$defs/timestamp" } }, "allOf": [ { "if": { "properties": { "analysis_mode": { "const": "deterministic" } } }, "then": { "properties": { "agent_run_id": { "const": null } } }, "else": { "properties": { "agent_run_id": { "type": "string" } } } }, diff --git a/schemas/review-presentation-v4.schema.json b/schemas/review-presentation-v4.schema.json index e821e24..bd07e84 100644 --- a/schemas/review-presentation-v4.schema.json +++ b/schemas/review-presentation-v4.schema.json @@ -9,10 +9,13 @@ "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "revision": { "type": "integer", "minimum": 0 }, "current_state": { "$ref": "#/$defs/current_state" }, "timeline": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/timeline" } }, "decisions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/decision" } }, "risks": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/risk" } }, "open_loops": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/open_loop" } }, - "problem_map_revision": { "type": "integer", "minimum": 0 }, "problem_root_ids": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, + "problem_map_revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "problem_root_ids": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, "problem_nodes": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/problem_node" } }, "chain_dependencies": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/chain_dependency" } }, "human_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "orphan_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "generated_baselines": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/baseline" } } }, + "allOf": [ + { "if": { "properties": { "problem_nodes": { "minItems": 1 } }, "required": ["problem_nodes"] }, "then": { "properties": { "problem_map_revision": { "minimum": 1 } } } } + ], "$defs": { "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "excerpt": { "type": "string", "maxLength": 4096 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "strings": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/text" } }, "current_state": { "type": "object", "additionalProperties": false, "required": ["goal", "stage", "status", "next_action", "last_verification"], "properties": { "goal": { "$ref": "#/$defs/text" }, "stage": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "next_action": { "$ref": "#/$defs/text" }, "last_verification": { "$ref": "#/$defs/text" } } }, @@ -21,14 +24,14 @@ "missing_reason": { "type": ["string", "null"], "enum": ["not_captured", "no_visible_answer", "no_execution_evidence", "not_verified", "source_unavailable", "partial_coverage", null] }, "closed_loop_segment": { "type": "object", "additionalProperties": false, "required": ["state", "text", "missing_reason", "source_turn_refs"], "properties": { "state": { "enum": ["present", "partial", "missing"] }, "text": { "$ref": "#/$defs/text" }, "missing_reason": { "$ref": "#/$defs/missing_reason" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" } }, "allOf": [{ "if": { "properties": { "state": { "const": "missing" } }, "required": ["state"] }, "then": { "properties": { "text": { "const": "" }, "missing_reason": { "type": "string" } } }, "else": { "properties": { "text": { "type": "string", "minLength": 1 }, "missing_reason": { "const": null } } } }] }, "closed_loop_conclusion": { "type": "object", "additionalProperties": false, "required": ["kind", "text", "missing_reason", "source_turn_refs"], "properties": { "kind": { "enum": ["visible_answer_excerpt", "human_confirmed", "ai_candidate_confirmed", "missing"] }, "text": { "$ref": "#/$defs/text" }, "missing_reason": { "$ref": "#/$defs/missing_reason" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" } }, "allOf": [{ "if": { "properties": { "kind": { "const": "missing" } }, "required": ["kind"] }, "then": { "properties": { "text": { "const": "" }, "missing_reason": { "type": "string" } } }, "else": { "properties": { "text": { "type": "string", "minLength": 1 }, "missing_reason": { "const": null } } } }, { "if": { "properties": { "kind": { "const": "visible_answer_excerpt" } }, "required": ["kind"] }, "then": { "properties": { "text": { "type": "string", "maxLength": 4096 } } } }] }, - "closed_loop_coverage": { "type": "object", "additionalProperties": false, "required": ["source_turns", "captured_turns", "truncated_turns", "source_unavailable_turns"], "properties": { "source_turns": { "type": "integer", "minimum": 0 }, "captured_turns": { "type": "integer", "minimum": 0 }, "truncated_turns": { "type": "integer", "minimum": 0 }, "source_unavailable_turns": { "type": "integer", "minimum": 0 } } }, + "closed_loop_coverage": { "type": "object", "additionalProperties": false, "required": ["source_turns", "captured_turns", "truncated_turns", "source_unavailable_turns"], "properties": { "source_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "captured_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "truncated_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "source_unavailable_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } } }, "closed_loop": { "type": "object", "additionalProperties": false, "required": ["trigger_question", "conclusion", "execution", "verification", "impact_and_follow_up", "source_turn_refs", "coverage"], "properties": { "trigger_question": { "$ref": "#/$defs/closed_loop_segment" }, "conclusion": { "$ref": "#/$defs/closed_loop_conclusion" }, "execution": { "$ref": "#/$defs/closed_loop_segment" }, "verification": { "$ref": "#/$defs/closed_loop_segment" }, "impact_and_follow_up": { "$ref": "#/$defs/closed_loop_segment" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" }, "coverage": { "$ref": "#/$defs/closed_loop_coverage" } } }, "timeline": { "type": "object", "additionalProperties": false, "required": ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids", "closed_loop"], "properties": { "id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "kind": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "summary": { "$ref": "#/$defs/text" }, "decision_ids": { "$ref": "#/$defs/id_array" }, "closed_loop": { "$ref": "#/$defs/closed_loop" } } }, "decision": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "occurred_at", "title", "rationale", "impact", "status", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "kind": { "enum": ["decision", "agreement"] }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "title": { "$ref": "#/$defs/text" }, "rationale": { "$ref": "#/$defs/text" }, "impact": { "$ref": "#/$defs/text" }, "status": { "enum": ["active", "superseded", "archived"] }, "reevaluate_when": { "$ref": "#/$defs/text" }, "supersedes": { "$ref": "#/$defs/id_array" }, "milestone_ids": { "$ref": "#/$defs/id_array" }, "session_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/session_ref" } }, "provenance": { "enum": ["human_created", "migrated", "ai_candidate_confirmed"] }, "pinned": { "type": "boolean" }, "revision": { "type": "integer", "minimum": 1 } } }, "session_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" } } }, "risk": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "detail"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "detail": { "$ref": "#/$defs/text" } } }, "open_loop": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "question", "next_experiment", "completion_criterion"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "question": { "$ref": "#/$defs/text" }, "next_experiment": { "$ref": "#/$defs/text" }, "completion_criterion": { "$ref": "#/$defs/text" } } }, - "problem_node": { "type": "object", "additionalProperties": false, "required": ["id", "question", "primary_parent_id", "related_node_ids", "workflow_state", "answer_state", "completion_criterion", "current_conclusion", "source_turn_refs", "provenance", "first_proposed_at", "sibling_order", "confirmed_at", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "question": { "$ref": "#/$defs/excerpt" }, "primary_parent_id": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "related_node_ids": { "type": "array", "maxItems": 2, "items": { "$ref": "#/$defs/id" } }, "workflow_state": { "enum": ["not_started", "in_progress", "paused", "resolved"] }, "answer_state": { "enum": ["no_answer", "answered_unverified", "execution_verified"] }, "completion_criterion": { "$ref": "#/$defs/text" }, "current_conclusion": { "$ref": "#/$defs/text" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" }, "provenance": { "enum": ["human_created", "migrated", "candidate_confirmed"] }, "first_proposed_at": { "$ref": "#/$defs/timestamp" }, "sibling_order": { "type": "integer", "minimum": 0 }, "confirmed_at": { "type": ["string", "null"], "maxLength": 128 }, "revision": { "type": "integer", "minimum": 1 } } }, + "problem_node": { "type": "object", "additionalProperties": false, "required": ["id", "question", "primary_parent_id", "related_node_ids", "workflow_state", "answer_state", "completion_criterion", "current_conclusion", "source_turn_refs", "provenance", "first_proposed_at", "sibling_order", "confirmed_at", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "question": { "$ref": "#/$defs/excerpt" }, "primary_parent_id": { "type": ["string", "null"], "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "related_node_ids": { "type": "array", "maxItems": 2, "items": { "$ref": "#/$defs/id" } }, "workflow_state": { "enum": ["not_started", "in_progress", "paused", "resolved"] }, "answer_state": { "enum": ["no_answer", "answered_unverified", "execution_verified"] }, "completion_criterion": { "$ref": "#/$defs/text" }, "current_conclusion": { "$ref": "#/$defs/text" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" }, "provenance": { "enum": ["human_created", "migrated", "candidate_confirmed"] }, "first_proposed_at": { "$ref": "#/$defs/timestamp" }, "sibling_order": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "confirmed_at": { "type": ["string", "null"], "maxLength": 128 }, "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 } } }, "chain_dependency": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "session_view_digest", "dependency_digest", "turn_unit_ids"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "session_view_digest": { "$ref": "#/$defs/digest" }, "dependency_digest": { "$ref": "#/$defs/digest" }, "turn_unit_ids": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } } } }, "patch": { "type": "object", "additionalProperties": false, "required": ["entity_id", "field", "operation", "base_generated_hash"], "properties": { "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "operation": { "enum": ["set", "suppress", "restore_default"] }, "value": { "$ref": "#/$defs/text" }, "values": { "$ref": "#/$defs/strings" }, "base_generated_hash": { "$ref": "#/$defs/sha256" } } }, "baseline": { "type": "object", "additionalProperties": false, "required": ["generation_id", "entity_id", "field", "kind", "generated_hash"], "properties": { "generation_id": { "$ref": "#/$defs/id" }, "entity_id": { "$ref": "#/$defs/id" }, "field": { "$ref": "#/$defs/id" }, "kind": { "$ref": "#/$defs/id" }, "value": { "$ref": "#/$defs/text" }, "values": { "$ref": "#/$defs/strings" }, "generated_hash": { "$ref": "#/$defs/sha256" } } }, From 5678f15c8b5fd7f7d2774fe03ca5c7847cfeae45 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 01:45:27 +0800 Subject: [PATCH 19/25] docs: record Gate 0 contract fix evidence --- docs/session-review/gate-0-evidence.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/session-review/gate-0-evidence.md b/docs/session-review/gate-0-evidence.md index 12ec84c..2c83587 100644 --- a/docs/session-review/gate-0-evidence.md +++ b/docs/session-review/gate-0-evidence.md @@ -4,15 +4,16 @@ **LOCAL COMPLETE / WINDOWS CI PENDING** -本地 Gate 0 已在实现提交 `e3ff49beb6cb28d4aacb73a5ba4f45c43289b112` 上重新通过。合同矩阵现已覆盖 `conversation-chain-v1`、`problem-map-candidate-v1`、正式问题图、演进闭环和通用 Agent annotation。该提交未推送,也没有原生 Windows 运行,因此 Windows CI 证据仍为 PENDING。 +本地 Gate 0 已在实现边界提交 `b30876db1d61026eb52f5d6d6533c052fd7a93b7` 上重新通过。合同矩阵现已覆盖 `conversation-chain-v1`、`problem-map-candidate-v1`、正式问题图、演进闭环和通用 Agent annotation,并完成了 Fix Round 1 的五项跨语言契约修正。该提交未推送,也没有原生 Windows 运行,因此 Windows CI 证据仍为 PENDING。 ## 审计对象 - 分支:`codex/obsidian-context-v4` - 任务基准:`6421a9aad6d7a65bbaef2fa71e4d7e7be3431db6` -- 实现提交:`e3ff49beb6cb28d4aacb73a5ba4f45c43289b112` +- 初始实现提交:`e3ff49beb6cb28d4aacb73a5ba4f45c43289b112` +- Fix Round 1 实现提交:`b30876db1d61026eb52f5d6d6533c052fd7a93b7` - 环境:`Darwin arm64`,`go1.26.5 darwin/arm64`,Node `v24.18.0`,npm `11.16.0` -- 实现提交统计:43 files changed,2,977 insertions,80 deletions +- Fix Round 1 提交统计:18 files changed,279 insertions,39 deletions - 未纳入任务提交:既有未跟踪目录 `.superpowers/brainstorm/` ## TDD 边界 @@ -27,6 +28,8 @@ go test ./internal/conversationchain ./internal/problemmap ./internal/reviewv4 - 相同命令在实现后 PASS:`internal/conversationchain` 0.428s、`internal/problemmap` 0.555s、`internal/reviewv4` 0.310s;Vitest 1/1 file、57/57 tests。随后增加 canonical digest tamper mirror,最终聚焦合同文件为 58/58 tests。 +Fix Round 1 先新增回归测试并获得预期 RED:CLI 编译报告 `ParseProblemContractWithInput` 未定义;Go 分别证明零 digest、超过 JavaScript safe integer 上限以及 schema 的非空图 revision-zero 会被错误接受;TypeScript 为 2 failed / 57 passed。修正后聚焦 Go 五个 package 全部 PASS,`contracts-v4.test.ts` 为 59/59 PASS。完整 RED/GREEN 原始输出保存在 Task 7 report 的 `Fix Round 1` 节。 + ## 完整本地门禁 按串行顺序执行: @@ -34,10 +37,10 @@ go test ./internal/conversationchain ./internal/problemmap ./internal/reviewv4 - | 命令 | 结果 | 证据 | |---|---|---| | `gofmt -w internal/conversationchain internal/problemmap internal/reviewv4 internal/cli` | PASS | 无输出 | -| `go test -p 1 -timeout 5m -count=1 ./...` | PASS | 57 个 package;较慢 package 包括 `internal/reviewjob` 89.595s、`internal/scan` 79.903s、`test/zerotoken` 62.772s,均低于每个测试二进制 5 分钟超时 | +| `go test -p 1 -timeout 5m -count=1 ./...` | PASS | Fix Round 1 后全 package 重跑;较慢 package 包括 `internal/scan` 114.532s、`internal/reviewjob` 55.855s、`test/zerotoken` 34.961s,均低于每个测试二进制 5 分钟超时 | | `go vet ./...` | PASS | exit 0,无输出 | | `go mod tidy -diff` | PASS | exit 0,无 diff | -| `cd obsidian-plugin && npm run check` | PASS | lint;17/17 test files、122/122 tests;TypeScript typecheck;production bundle | +| `cd obsidian-plugin && npm run check` | PASS | lint;17/17 test files、123/123 tests;TypeScript typecheck;production bundle | | `git diff --check` | PASS | exit 0,无输出 | 独立 ordinary-flow 复核 `go test ./test/zerotoken -count=1 -run 'TestGate(A|B)' -v` PASS:Gate A 154/154 terminal、151 indexed、zero model tokens;Gate B 端到端发布与幂等测试通过。新增 deterministic candidate fixture 明确要求 `agent_run_id=null`,本任务没有启动或实现 Agent 执行。 @@ -69,6 +72,9 @@ go test ./internal/conversationchain ./internal/problemmap ./internal/reviewv4 - - 缺失结论必须为空文本并携带 typed reason;milestone annotation 使用通用 `confirmed_entity_id`、source-turn dependency,禁止 decision-only fields。 - v4/partial 兼容 fixture 仅增加空问题图、空 chain dependencies 和 neutral closed-loop 默认;对应 review hash 与 ledger self hash 已机械重算。v3 语义未改。 - 64 KiB per-source ceiling 仅冻结为 CLI 常量与显式 truncation coverage 合同;未实现 SourceAdapter 读取行为。 +- `problem reorder` 的完整直接子节点顺序由最大 64 KiB、封闭且带 `schema_version=1` 的 stdin JSON 载荷携带;命令行仍不接受任意文件或路径输入。 +- 空正式问题图允许 revision 0 并可作为第一次 apply 的 CAS 前像;非空图在 Go、TypeScript 和 JSON Schema 中都要求正 revision。 +- 两个新增持久化合同在 parse 边界无条件验证 canonical digest,全零 digest 不再作为绕过值;新增/扩展整数线统一上限为 `9007199254740991`。 ## Windows 证据状态 From d81e68a9fe4bc656ce5c29cb7369a4924a2d3a7b Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 02:40:44 +0800 Subject: [PATCH 20/25] fix: harden Gate 0 compatibility contracts --- ...idian-project-context-navigation-design.md | 11 +- internal/memory/api_compat_test.go | 69 +++++++++- internal/migrationv4/migrate.go | 16 +-- internal/migrationv4/migrate_test.go | 57 ++++++-- internal/reviewv4/codec_test.go | 124 ++++++++++++++++++ internal/reviewv4/types.go | 36 ++--- internal/reviewv4/validate.go | 83 +++++++++++- internal/sessionindex/types.go | 4 +- internal/sessionindex/validate.go | 26 +++- internal/sessionindex/validate_test.go | 32 ++++- internal/syncproject/migration.go | 29 +++- internal/syncproject/service_test.go | 21 +++ obsidian-plugin/src/contracts/review-v4.ts | 7 +- obsidian-plugin/src/data/contracts-v4.ts | 69 ++++++++-- obsidian-plugin/tests/contracts-v4.test.ts | 75 ++++++++++- .../v4/session-index-v1.unknown.valid.json | 18 +++ schemas/machine-ledger-v4.schema.json | 1 + schemas/review-presentation-v4.schema.json | 2 +- schemas/session-index-v1.schema.json | 2 +- .../v4/session-index-v1.unknown.valid.json | 18 +++ 20 files changed, 628 insertions(+), 72 deletions(-) create mode 100644 obsidian-plugin/tests/fixtures/v4/session-index-v1.unknown.valid.json create mode 100644 testdata/contracts/v4/session-index-v1.unknown.valid.json diff --git a/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md index 3499087..6f0d12c 100644 --- a/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md +++ b/docs/superpowers/specs/2026-09-04-obsidian-project-context-navigation-design.md @@ -560,6 +560,8 @@ last_successful_generation_id | null 索引身份是 `(project_id, provider, session_id)`。`session_id` 只需在同一 provider 内唯一,界面和所有 CLI 命令始终同时携带 provider。相同身份在新世代中形成新索引修订,不改写旧世代的规范字节。 +`started_at` 和 `ended_at` 只有在来源真实提供时才写入字符串;不可知时必须为 `null`,不得从另一时间、扫描时间或迁移时间补写。`started_at_known` 和 `ended_at_known` 分别只统计非 `null` 值;规范排序为 `started_at desc nulls last, provider asc, session_id asc`。 + 所有数组有明确最大项数,所有字符串有 UTF-8 字节上限。`state_reason_codes` 只能使用版本化枚举;用户可见说明由插件本地化,机器文件中不保存任意错误文本。 ### 17.2 Session 摘要 @@ -699,7 +701,8 @@ occurred_at title rationale impact -status = active | superseded | archived +status = active | superseded | archived | legacy_unmapped +legacy_status_text | null reevaluate_when supersedes[] milestone_ids[] @@ -709,7 +712,7 @@ pinned revision ~~~ -替代关系必须无环;`status=superseded` 时至少存在一个后继条目直接引用该条目,后继自身可以在以后继续被替代。迁移无法恢复的新增字段使用空值、空数组或 `false`,并保留 `provenance=migrated`,不得推断理由或关系。 +替代关系必须无环;`status=superseded` 时至少存在一个后继条目直接引用该条目,后继自身可以在以后继续被替代。原生 v4 决策只使用 `active|superseded|archived`,且 `legacy_status_text=null`。v3 状态只有在原文精确为 `active` 或 `archived` 时映射到同名 v4 状态;其他任意原文(包括空串、`superseded` 和人类语言文本)都表示为 `status=legacy_unmapped`、`provenance=migrated`,并在 `legacy_status_text` 中逐字保存原值。`legacy_unmapped` 必须携带非 `null` 的 `legacy_status_text`,不允许用于原生决策。迁移无法恢复的其他新增字段使用空值、空数组或 `false`,不得推断理由或关系。 ### 18.3 问题节点与归位候选状态机 @@ -909,7 +912,7 @@ audit_reason 两个价格目录响应分别设置 128 MiB 下载与解析上限,要求成功 HTTP 状态、JSON content type、受支持 schema、无重复字段和完整响应体;使用平台私有权限目录、进程锁和原子替换保存。刷新失败时保留上一份已验证缓存,不用半文件覆盖,也不把失败时间写成新的 `retrieved_at`。 -目录刷新不修改快照。补价或纠错创建新快照,并通过 `supersedes_snapshot_id` 指向旧快照;聚合只选择每条用量的最新有效快照,但审计视图可以查看完整链。ModelPriceWatch、官方来源和人工补充的优先级不覆盖适用条件检查:任何条件不明都先进入 `pending` 或 `ambiguous`。 +目录刷新不修改快照。补价或纠错创建新快照,并通过 `supersedes_snapshot_id` 指向旧快照;聚合只选择每条用量的最新有效快照,但审计视图可以查看完整链。`machine-ledger-v4` 必须对整本账本验证价格替代图:前驱必须存在,不得自指、成环或分叉;前驱与后继必须共享完全相同的 `(provider, session_id, usage_record_digest)`;有后继的快照必须为 `superseded`,`superseded` 也必须有后继。`current_pricing_snapshot_ids` 只能选择非 `superseded` 叶子,每个用量身份最多一个;一旦某身份被选中,该身份不得还存在另一个断开的有效叶子。未选中的历史快照仍是不可变审计证据;未知费率和成本继续用 `null`,不得因图校验升级为零。ModelPriceWatch、官方来源和人工补充的优先级不覆盖适用条件检查:任何条件不明都先进入 `pending` 或 `ambiguous`。 ## 19. 兼容与迁移矩阵 @@ -940,7 +943,7 @@ dry-run 返回版本化迁移预览、将保留或补默认值的语义单元、 迁移必须满足: 1. dry-run 列出将新增、升级和保留的合同,不写文件; -2. v2/v3 决策的标题、理由、影响、状态、稳定 ID 和现有来源关系逐字节保留; +2. v2/v3 决策的标题、理由、影响、稳定 ID 和现有来源关系逐字节保留;状态原文若不能按上述封闭规则精确映射,用 `legacy_unmapped + legacy_status_text` 无损表示; 3. 新字段只填显式默认值,不由机器补写理由、重评条件或替代关系; 4. v3 `recent-progress` 仅在四文件新世代成功发布后从人类页面移除; 5. 旧价格保留为迁移快照,无法证明来源或日期时标为 `legacy_unverified`,不重算; diff --git a/internal/memory/api_compat_test.go b/internal/memory/api_compat_test.go index af66577..e853009 100644 --- a/internal/memory/api_compat_test.go +++ b/internal/memory/api_compat_test.go @@ -113,6 +113,34 @@ func TestExpandedV4SchemasEnforceRevisionAndSafeIntegerBoundaries(t *testing.T) } } +func TestV4SchemasAllowHonestUnknownSessionTimesAndLosslessLegacyDecisionStatus(t *testing.T) { + indexSchema := readContractJSON(t, filepath.Join("..", "..", "schemas", "session-index-v1.schema.json")) + index := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", "session-index-v1.unknown.valid.json")) + if err := validateContractSchema(indexSchema, index, "$", indexSchema); err != nil { + t.Fatalf("schema rejected unknown session timestamps: %v", err) + } + if err := validateSessionIndexCoverage(index); err != nil { + t.Fatalf("schema fixture coverage rejected unknown session timestamps: %v", err) + } + + reviewSchema := readContractJSON(t, filepath.Join("..", "..", "schemas", "review-presentation-v4.schema.json")) + review := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", "review-presentation-v4.valid.json")).(map[string]any) + legacy := map[string]any{ + "id": "legacy-decision", "kind": "decision", "occurred_at": "2026-08-25", "title": "Keep human status", + "rationale": "preserve", "impact": "compatibility", "status": "legacy_unmapped", "legacy_status_text": "已采用", + "reevaluate_when": "", "supersedes": []any{}, "milestone_ids": []any{}, "session_refs": []any{}, + "provenance": "migrated", "pinned": false, "revision": json.Number("1"), + } + review["decisions"] = []any{legacy} + if err := validateContractSchema(reviewSchema, review, "$", reviewSchema); err != nil { + t.Fatalf("schema rejected lossless legacy status: %v", err) + } + legacy["status"] = "active" + if err := validateContractSchema(reviewSchema, review, "$", reviewSchema); err == nil { + t.Fatal("schema accepted legacy status text on a native v4 decision") + } +} + func TestV4ContractFixtureDecoderRejectsUnsafeBoundaries(t *testing.T) { cases := []struct { name string @@ -581,7 +609,46 @@ func validateSessionIndexCoverage(value any) error { if err != nil { return err } - if complete+partial+errCount+unprocessed != total || available+unavailable != total || int64(len(sessions)) != total { + calculatedStates := map[string]int64{"complete": 0, "partial": 0, "error": 0, "unprocessed": 0} + calculatedSources := map[string]int64{"available": 0, "unavailable": 0} + var startedKnown, endedKnown, usageKnown int64 + for _, item := range sessions { + session, ok := item.(map[string]any) + if !ok { + return fmt.Errorf("session entry is not an object") + } + state, stateOK := session["processing_state"].(string) + availability, availabilityOK := session["source_availability"].(string) + if !stateOK || !availabilityOK { + return fmt.Errorf("session state or availability is not a string") + } + calculatedStates[state]++ + calculatedSources[availability]++ + if session["started_at"] != nil { + startedKnown++ + } + if session["ended_at"] != nil { + endedKnown++ + } + if session["usage_record_digest"] != nil { + usageKnown++ + } + } + claimedStarted, err := get("started_at_known") + if err != nil { + return err + } + claimedEnded, err := get("ended_at_known") + if err != nil { + return err + } + claimedUsage, err := get("usage_known") + if err != nil { + return err + } + if complete != calculatedStates["complete"] || partial != calculatedStates["partial"] || errCount != calculatedStates["error"] || unprocessed != calculatedStates["unprocessed"] || + available != calculatedSources["available"] || unavailable != calculatedSources["unavailable"] || claimedStarted != startedKnown || claimedEnded != endedKnown || claimedUsage != usageKnown || + complete+partial+errCount+unprocessed != total || available+unavailable != total || int64(len(sessions)) != total { return fmt.Errorf("coverage counts do not reconcile") } return nil diff --git a/internal/migrationv4/migrate.go b/internal/migrationv4/migrate.go index 590d823..92a6cbf 100644 --- a/internal/migrationv4/migrate.go +++ b/internal/migrationv4/migrate.go @@ -118,13 +118,11 @@ func migratePresentation(source reviewv2.AcceptedV3, generationID, projectDigest result.Timeline = append(result.Timeline, reviewv4.Timeline{ID: event.ID, GenerationID: generationID, OccurredAt: event.OccurredAt, Kind: event.Kind, Title: event.Title, Summary: event.Summary, DecisionIDs: append([]string{}, event.DecisionIDs...), ClosedLoop: reviewv4.NeutralClosedLoop()}) } for _, decision := range state.Review.Decisions { - status, err := migrateDecisionStatus(decision.Status) - if err != nil { - return reviewv4.Presentation{}, fmt.Errorf("decision %q: %w", decision.ID, err) - } + status, legacyStatusText := migrateDecisionStatus(decision.Status) result.Decisions = append(result.Decisions, reviewv4.Decision{ ID: decision.ID, Kind: "decision", OccurredAt: decision.OccurredAt, Title: decision.Title, Rationale: decision.Rationale, Impact: decision.Impact, Status: status, - ReevaluateWhen: "", Supersedes: []string{}, MilestoneIDs: []string{}, SessionRefs: []reviewv4.SessionRef{}, Provenance: "migrated", Pinned: false, Revision: 1, + LegacyStatusText: legacyStatusText, + ReevaluateWhen: "", Supersedes: []string{}, MilestoneIDs: []string{}, SessionRefs: []reviewv4.SessionRef{}, Provenance: "migrated", Pinned: false, Revision: 1, }) } for _, risk := range state.Review.Risks { @@ -139,16 +137,14 @@ func migratePresentation(source reviewv2.AcceptedV3, generationID, projectDigest return result, nil } -func migrateDecisionStatus(status string) (reviewv4.DecisionStatus, error) { +func migrateDecisionStatus(status string) (reviewv4.DecisionStatus, *string) { switch status { - case "", "active": + case "active": return reviewv4.DecisionActive, nil case "archived": return reviewv4.DecisionArchived, nil - case "superseded": - return "", errors.New("superseded status cannot be represented without inventing a successor") default: - return "", fmt.Errorf("legacy decision status %q has no exact v4 mapping", status) + return reviewv4.DecisionLegacyUnmapped, &status } } diff --git a/internal/migrationv4/migrate_test.go b/internal/migrationv4/migrate_test.go index 1105c10..66970d8 100644 --- a/internal/migrationv4/migrate_test.go +++ b/internal/migrationv4/migrate_test.go @@ -159,7 +159,7 @@ func TestMigrateAcceptedV3PreservesDecisionWithoutInventingFields(t *testing.T) decision := result.Accepted.Review.Decisions[0] if decision.ID != "decision-1" || decision.Title != "Keep v3" || decision.Rationale != "because" || decision.Impact != "scope" || decision.Kind != "decision" || decision.Status != reviewv4.DecisionActive || decision.Provenance != "migrated" || decision.Pinned || decision.Revision != 1 || - decision.ReevaluateWhen != "" || len(decision.Supersedes) != 0 || len(decision.MilestoneIDs) != 0 || len(decision.SessionRefs) != 0 { + decision.LegacyStatusText != nil || decision.ReevaluateWhen != "" || len(decision.Supersedes) != 0 || len(decision.MilestoneIDs) != 0 || len(decision.SessionRefs) != 0 { t.Fatalf("migration lost or invented decision data: %+v", decision) } if _, err := reviewv4.LoadProjection(result.Review, result.History, result.Ledger, result.SessionIndex); err != nil { @@ -167,6 +167,21 @@ func TestMigrateAcceptedV3PreservesDecisionWithoutInventingFields(t *testing.T) } } +func TestMigrateDecisionStatusOnlyMapsExactNativeStates(t *testing.T) { + for _, status := range []string{"", "superseded", "已采用", "ACTIVE"} { + mapped, original := migrateDecisionStatus(status) + if mapped != reviewv4.DecisionLegacyUnmapped || original == nil || *original != status { + t.Fatalf("legacy status %q was guessed or lost: mapped=%q original=%v", status, mapped, original) + } + } + for _, status := range []string{"active", "archived"} { + mapped, original := migrateDecisionStatus(status) + if string(mapped) != status || original != nil { + t.Fatalf("exact native status %q was not mapped exactly: mapped=%q original=%v", status, mapped, original) + } + } +} + func TestMigrateAcceptedV3PreservesLegacyUsageAsUnverifiedPricingEvidence(t *testing.T) { review, history, machine, indexBody := migrationFixture(t) source, err := reviewv2.LoadV3Bytes(review, history, machine) @@ -198,7 +213,7 @@ func TestMigrateAcceptedV3PreservesLegacyUsageAsUnverifiedPricingEvidence(t *tes index.Sessions = []sessionindex.Entry{{ Provider: "codex", SessionID: "session-legacy", ProcessingState: sessionindex.ProcessingComplete, StateReasonCodes: []string{}, SourceAvailability: "available", SourceTerminalState: &terminal, - StartedAt: account.StartedAt, EndedAt: account.EndedAt, DurationMS: &duration, RecordCount: &records, + StartedAt: stringPtr(account.StartedAt), EndedAt: stringPtr(account.EndedAt), DurationMS: &duration, RecordCount: &records, Coverage: sessionindex.Coverage{}, FactCounts: sessionindex.FactCounts{}, SessionViewDigest: &sessionDigest, UsageRecordDigest: &usageDigest, LastSeenGenerationID: &lastGeneration, LastSuccessfulGenerationID: &lastGeneration, }} @@ -336,7 +351,7 @@ func TestMigrationPreviewRejectsStaleDigestAndInvalidBindings(t *testing.T) { } } -func TestMigrateAcceptedV3RejectsMixedProjectGenerationAndUnmappableStatus(t *testing.T) { +func TestMigrateAcceptedV3RejectsMixedProjectAndGeneration(t *testing.T) { review, history, machine, index := migrationFixture(t) parsedIndex, err := sessionindex.Parse(index) if err != nil { @@ -360,11 +375,6 @@ func TestMigrateAcceptedV3RejectsMixedProjectGenerationAndUnmappableStatus(t *te t.Fatal("mixed generation index was accepted") } - badStatus := bytes.Replace(review, []byte("#### \u72b6\u6001\nactive"), []byte("#### \u72b6\u6001\nmaybe"), 1) - badMachine := rebindV3ReviewHash(t, machine, badStatus) - if _, err := MigrateAcceptedV3(badStatus, history, badMachine, index); err == nil { - t.Fatal("unmappable decision status was accepted") - } } func rebindV3ReviewHash(t *testing.T, machine, review []byte) []byte { @@ -381,6 +391,35 @@ func rebindV3ReviewHash(t *testing.T, machine, review []byte) []byte { return body } +func TestMigrateAcceptedV3PreservesReleasedHumanDecisionStatus(t *testing.T) { + released, err := os.ReadFile("../../testdata/review-v3/项目回顾.valid.md") + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(released, []byte("#### 状态\n已采用")) { + t.Fatal("released compatibility fixture no longer carries the expected human status") + } + + review, history, machine, index := migrationFixture(t) + review = bytes.Replace(review, []byte("#### \u72b6\u6001\nactive"), []byte("#### \u72b6\u6001\n已采用"), 1) + machine = rebindV3ReviewHash(t, machine, review) + result, err := MigrateAcceptedV3Result(review, history, machine, index) + if err != nil { + t.Fatalf("released human decision status blocked migration: %v", err) + } + var presentation map[string]any + if err := json.Unmarshal(result.Review, &presentation); err != nil { + t.Fatal(err) + } + decision := presentation["decisions"].([]any)[0].(map[string]any) + if decision["status"] != "legacy_unmapped" || decision["legacy_status_text"] != "已采用" { + t.Fatalf("legacy status was not represented losslessly: %+v", decision) + } + if _, err := reviewv4.LoadProjection(result.Review, result.History, result.Ledger, result.SessionIndex); err != nil { + t.Fatalf("migrated legacy status is not readable as v4: %v", err) + } +} + func cloneInput(input Input) Input { result := input result.Review = append([]byte(nil), input.Review...) @@ -435,3 +474,5 @@ func migrationFixture(t *testing.T) ([]byte, []byte, []byte, []byte) { } func bareHash(body []byte) string { return fmt.Sprintf("%x", sha256.Sum256(body)) } + +func stringPtr(value string) *string { return &value } diff --git a/internal/reviewv4/codec_test.go b/internal/reviewv4/codec_test.go index ec9a0a0..a87ec20 100644 --- a/internal/reviewv4/codec_test.go +++ b/internal/reviewv4/codec_test.go @@ -77,6 +77,61 @@ func TestValidatePresentationRejectsDecisionCycleAndBrokenGraph(t *testing.T) { } } +func TestDecodePresentationAcceptsOnlyLosslessLegacyDecisionStatusShape(t *testing.T) { + presentation := minimumPresentation() + body, err := json.Marshal(presentation) + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatal(err) + } + legacy := map[string]any{ + "id": "legacy-decision", "kind": "decision", "occurred_at": "2026-08-25", "title": "Keep human status", + "rationale": "preserve", "impact": "compatibility", "status": "legacy_unmapped", "legacy_status_text": "已采用", + "reevaluate_when": "", "supersedes": []any{}, "milestone_ids": []any{}, "session_refs": []any{}, + "provenance": "migrated", "pinned": false, "revision": float64(1), + } + raw["decisions"] = []any{legacy} + valid, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if _, err := DecodePresentation(valid); err != nil { + t.Fatalf("lossless legacy decision status was rejected: %v", err) + } + + for name, mutate := range map[string]func(map[string]any){ + "missing original text": func(value map[string]any) { value["legacy_status_text"] = nil }, + "native provenance": func(value map[string]any) { value["provenance"] = "human_created" }, + "native status carries legacy text": func(value map[string]any) { + value["status"] = "active" + }, + } { + t.Run(name, func(t *testing.T) { + copy := cloneMap(legacy) + mutate(copy) + raw["decisions"] = []any{copy} + invalid, marshalErr := json.Marshal(raw) + if marshalErr != nil { + t.Fatal(marshalErr) + } + if _, decodeErr := DecodePresentation(invalid); decodeErr == nil { + t.Fatal("accepted invalid legacy decision representation") + } + }) + } +} + +func cloneMap(value map[string]any) map[string]any { + result := make(map[string]any, len(value)) + for key, item := range value { + result[key] = item + } + return result +} + func TestLoadProjectionEnforcesAllIdentityAndDigestBindings(t *testing.T) { reviewFixture := mustRead(t, "../../testdata/contracts/v4/review-presentation-v4.valid.json") indexFixture := mustRead(t, "../../testdata/contracts/v4/session-index-v1.valid.json") @@ -401,7 +456,9 @@ func TestValidatePresentationProblemMapRevisionAndSafeIntegerBoundary(t *testing func TestValidateLedgerUsesOnlyCurrentPricingForAggregateCompleteness(t *testing.T) { ledger := frozenLedger(t) historical := ledger.PricingSnapshots[0] + historical.Status = pricing.PriceSuperseded current := completePricingSnapshot(t, "snapshot-current") + current.SupersedesSnapshotID = stringPointer(historical.SnapshotID) zero := 0.0 ledger.PricingSnapshots = []pricing.Snapshot{historical, current} ledger.CurrentPricingSnapshotIDs = []string{current.SnapshotID} @@ -411,6 +468,73 @@ func TestValidateLedgerUsesOnlyCurrentPricingForAggregateCompleteness(t *testing } } +func TestValidateLedgerEnforcesPricingSupersessionGraphAndCurrentLeaf(t *testing.T) { + validChain := frozenLedger(t) + predecessor := validChain.PricingSnapshots[0] + predecessor.Status = pricing.PriceSuperseded + successor := completePricingSnapshot(t, "snapshot-successor") + successor.SupersedesSnapshotID = stringPointer(predecessor.SnapshotID) + validChain.PricingSnapshots = []pricing.Snapshot{predecessor, successor} + validChain.CurrentPricingSnapshotIDs = []string{successor.SnapshotID} + zero := 0.0 + validChain.Accounting.TotalCostUSD = &zero + if err := ValidateLedger(validChain); err != nil { + t.Fatalf("valid linear pricing history rejected: %v", err) + } + + tests := []struct { + name string + mutate func(*MachineLedger) + }{ + {name: "missing predecessor", mutate: func(ledger *MachineLedger) { + ledger.PricingSnapshots[1].SupersedesSnapshotID = stringPointer("missing") + }}, + {name: "self reference", mutate: func(ledger *MachineLedger) { + ledger.PricingSnapshots[1].SupersedesSnapshotID = stringPointer(ledger.PricingSnapshots[1].SnapshotID) + }}, + {name: "cycle", mutate: func(ledger *MachineLedger) { + ledger.PricingSnapshots[0].SupersedesSnapshotID = stringPointer(ledger.PricingSnapshots[1].SnapshotID) + }}, + {name: "identity mismatch", mutate: func(ledger *MachineLedger) { + ledger.PricingSnapshots[1].SessionID = "other-session" + }}, + {name: "provider mismatch", mutate: func(ledger *MachineLedger) { + ledger.PricingSnapshots[1].Provider = "claude" + }}, + {name: "usage record mismatch", mutate: func(ledger *MachineLedger) { + ledger.PricingSnapshots[1].UsageRecordDigest = "sha256:" + strings.Repeat("2", 64) + }}, + {name: "branching successors", mutate: func(ledger *MachineLedger) { + branch := ledger.PricingSnapshots[1] + branch.SnapshotID = "snapshot-branch" + ledger.PricingSnapshots = append(ledger.PricingSnapshots, branch) + }}, + {name: "non-leaf selected", mutate: func(ledger *MachineLedger) { + ledger.PricingSnapshots[0].Status = pricing.PriceCurrent + ledger.CurrentPricingSnapshotIDs = []string{ledger.PricingSnapshots[0].SnapshotID} + }}, + {name: "multiple effective leaves", mutate: func(ledger *MachineLedger) { + branch := ledger.PricingSnapshots[1] + branch.SnapshotID = "snapshot-branch" + branch.SupersedesSnapshotID = nil + ledger.PricingSnapshots = append(ledger.PricingSnapshots, branch) + }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + ledger := validChain + ledger.PricingSnapshots = append([]pricing.Snapshot(nil), validChain.PricingSnapshots...) + ledger.CurrentPricingSnapshotIDs = append([]string(nil), validChain.CurrentPricingSnapshotIDs...) + tc.mutate(&ledger) + if err := ValidateLedger(ledger); err == nil { + t.Fatal("accepted malformed pricing supersession ledger") + } + }) + } +} + +func stringPointer(value string) *string { return &value } + func TestValidateLedgerRequiresNullAggregateWhenModelCostUnknown(t *testing.T) { ledger := frozenLedger(t) one := 1.0 diff --git a/internal/reviewv4/types.go b/internal/reviewv4/types.go index f6c12da..c3c751a 100644 --- a/internal/reviewv4/types.go +++ b/internal/reviewv4/types.go @@ -18,9 +18,10 @@ const ( type DecisionStatus string const ( - DecisionActive DecisionStatus = "active" - DecisionSuperseded DecisionStatus = "superseded" - DecisionArchived DecisionStatus = "archived" + DecisionActive DecisionStatus = "active" + DecisionSuperseded DecisionStatus = "superseded" + DecisionArchived DecisionStatus = "archived" + DecisionLegacyUnmapped DecisionStatus = "legacy_unmapped" ) type CandidateStatus string @@ -132,20 +133,21 @@ type SessionRef struct { SessionID string `json:"session_id" required:"true"` } type Decision struct { - ID string `json:"id" required:"true"` - Kind string `json:"kind" required:"true"` - OccurredAt string `json:"occurred_at" required:"true"` - Title string `json:"title" required:"true"` - Rationale string `json:"rationale" required:"true"` - Impact string `json:"impact" required:"true"` - Status DecisionStatus `json:"status" required:"true"` - ReevaluateWhen string `json:"reevaluate_when" required:"true"` - Supersedes []string `json:"supersedes" required:"true"` - MilestoneIDs []string `json:"milestone_ids" required:"true"` - SessionRefs []SessionRef `json:"session_refs" required:"true"` - Provenance string `json:"provenance" required:"true"` - Pinned bool `json:"pinned" required:"true"` - Revision int `json:"revision" required:"true"` + ID string `json:"id" required:"true"` + Kind string `json:"kind" required:"true"` + OccurredAt string `json:"occurred_at" required:"true"` + Title string `json:"title" required:"true"` + Rationale string `json:"rationale" required:"true"` + Impact string `json:"impact" required:"true"` + Status DecisionStatus `json:"status" required:"true"` + LegacyStatusText *string `json:"legacy_status_text" required:"true" nullable:"true"` + ReevaluateWhen string `json:"reevaluate_when" required:"true"` + Supersedes []string `json:"supersedes" required:"true"` + MilestoneIDs []string `json:"milestone_ids" required:"true"` + SessionRefs []SessionRef `json:"session_refs" required:"true"` + Provenance string `json:"provenance" required:"true"` + Pinned bool `json:"pinned" required:"true"` + Revision int `json:"revision" required:"true"` } type Risk struct { ID string `json:"id" required:"true"` diff --git a/internal/reviewv4/validate.go b/internal/reviewv4/validate.go index f7bad42..e23406b 100644 --- a/internal/reviewv4/validate.go +++ b/internal/reviewv4/validate.go @@ -67,7 +67,7 @@ func ValidatePresentation(p Presentation) error { } decisions := map[string]Decision{} for i, decision := range p.Decisions { - if !validID(decision.ID) || len(decision.OccurredAt) > 128 || !text(decision.Title, 16384) || !text(decision.Rationale, 16384) || !text(decision.Impact, 16384) || !text(decision.ReevaluateWhen, 16384) || decision.Revision < 1 || len(decision.Supersedes) > 256 || len(decision.MilestoneIDs) > 256 || len(decision.SessionRefs) > 256 { + if !validID(decision.ID) || len(decision.OccurredAt) > 128 || !text(decision.Title, 16384) || !text(decision.Rationale, 16384) || !text(decision.Impact, 16384) || !optionalText(decision.LegacyStatusText, 16384) || !text(decision.ReevaluateWhen, 16384) || decision.Revision < 1 || len(decision.Supersedes) > 256 || len(decision.MilestoneIDs) > 256 || len(decision.SessionRefs) > 256 { return fmt.Errorf("invalid decision %d", i) } if _, exists := decisions[decision.ID]; exists { @@ -80,6 +80,13 @@ func ValidatePresentation(p Presentation) error { } switch decision.Status { case DecisionActive, DecisionSuperseded, DecisionArchived: + if decision.LegacyStatusText != nil { + return errors.New("native decision status cannot carry legacy status text") + } + case DecisionLegacyUnmapped: + if decision.LegacyStatusText == nil || decision.Provenance != "migrated" { + return errors.New("legacy unmapped decision requires migrated provenance and exact status text") + } default: return errors.New("invalid decision status") } @@ -568,14 +575,29 @@ func ValidateLedger(l MachineLedger) error { } seenCurrent := map[string]bool{} currentPricingIncomplete := false + successorCounts, err := validatePricingSupersessionGraph(pricingByID) + if err != nil { + return err + } + currentByIdentity := map[string]string{} for _, id := range l.CurrentPricingSnapshotIDs { snapshot, exists := pricingByID[id] - if !validID(id) || !exists || seenCurrent[id] || snapshot.Status == pricing.PriceSuperseded { + if !validID(id) || !exists || seenCurrent[id] || snapshot.Status == pricing.PriceSuperseded || successorCounts[id] != 0 { return errors.New("invalid current pricing snapshot reference") } seenCurrent[id] = true + identity := pricingIdentity(snapshot) + if _, exists := currentByIdentity[identity]; exists { + return errors.New("multiple current pricing snapshots for one usage record") + } + currentByIdentity[identity] = id currentPricingIncomplete = currentPricingIncomplete || !snapshot.PricingComplete } + for id, snapshot := range pricingByID { + if selected, exists := currentByIdentity[pricingIdentity(snapshot)]; exists && successorCounts[id] == 0 && selected != id { + return errors.New("multiple effective pricing leaves for one usage record") + } + } if currentPricingIncomplete && l.Accounting.TotalCostUSD != nil { return errors.New("aggregate price must be null when a current snapshot is incomplete") } @@ -588,6 +610,63 @@ func ValidateLedger(l MachineLedger) error { return nil } +func pricingIdentity(snapshot pricing.Snapshot) string { + return snapshot.Provider + "\x00" + snapshot.SessionID + "\x00" + snapshot.UsageRecordDigest +} + +func validatePricingSupersessionGraph(byID map[string]pricing.Snapshot) (map[string]int, error) { + successors := make(map[string]int, len(byID)) + for id, snapshot := range byID { + if snapshot.SupersedesSnapshotID == nil { + continue + } + predecessorID := *snapshot.SupersedesSnapshotID + predecessor, exists := byID[predecessorID] + if !exists { + return nil, fmt.Errorf("pricing snapshot %q has missing predecessor %q", id, predecessorID) + } + if predecessorID == id { + return nil, errors.New("pricing snapshot cannot supersede itself") + } + if pricingIdentity(predecessor) != pricingIdentity(snapshot) { + return nil, errors.New("pricing predecessor and successor identity mismatch") + } + successors[predecessorID]++ + if successors[predecessorID] > 1 { + return nil, errors.New("pricing supersession graph branches into multiple leaves") + } + } + state := map[string]uint8{} + var visit func(string) error + visit = func(id string) error { + if state[id] == 1 { + return errors.New("pricing supersession graph contains cycle") + } + if state[id] == 2 { + return nil + } + state[id] = 1 + if predecessor := byID[id].SupersedesSnapshotID; predecessor != nil { + if err := visit(*predecessor); err != nil { + return err + } + } + state[id] = 2 + return nil + } + for id := range byID { + if err := visit(id); err != nil { + return nil, err + } + } + for id, snapshot := range byID { + if (successors[id] > 0) != (snapshot.Status == pricing.PriceSuperseded) { + return nil, errors.New("pricing supersession status does not match graph position") + } + } + return successors, nil +} + func money(value *float64) bool { return value == nil || (!math.IsNaN(*value) && !math.IsInf(*value, 0) && *value >= 0) } diff --git a/internal/sessionindex/types.go b/internal/sessionindex/types.go index 0f8abda..a43a8ab 100644 --- a/internal/sessionindex/types.go +++ b/internal/sessionindex/types.go @@ -53,8 +53,8 @@ type Entry struct { StateReasonCodes []string `json:"state_reason_codes" required:"true"` SourceAvailability string `json:"source_availability" required:"true"` SourceTerminalState *string `json:"source_terminal_state" required:"true" nullable:"true"` - StartedAt string `json:"started_at" required:"true"` - EndedAt string `json:"ended_at" required:"true"` + StartedAt *string `json:"started_at" required:"true" nullable:"true"` + EndedAt *string `json:"ended_at" required:"true" nullable:"true"` DurationMS *uint64 `json:"duration_ms" required:"true" nullable:"true"` WarningCount uint64 `json:"warning_count" required:"true"` RecordCount *uint64 `json:"record_count" required:"true" nullable:"true"` diff --git a/internal/sessionindex/validate.go b/internal/sessionindex/validate.go index f9c50d6..1a63791 100644 --- a/internal/sessionindex/validate.go +++ b/internal/sessionindex/validate.go @@ -81,11 +81,15 @@ func Validate(document Document) error { default: return fmt.Errorf("invalid source availability at %d", index) } - if entry.StartedAt == "" || len(entry.StartedAt) > 128 || entry.EndedAt == "" || len(entry.EndedAt) > 128 || !validOptional(entry.SourceTerminalState, 64) { + if !validTimestamp(entry.StartedAt) || !validTimestamp(entry.EndedAt) || !validOptional(entry.SourceTerminalState, 64) { return fmt.Errorf("invalid session timestamps at %d", index) } - calculated.StartedAtKnown++ - calculated.EndedAtKnown++ + if entry.StartedAt != nil { + calculated.StartedAtKnown++ + } + if entry.EndedAt != nil { + calculated.EndedAtKnown++ + } if entry.UsageRecordDigest != nil { calculated.UsageKnown++ } @@ -121,8 +125,16 @@ func Validate(document Document) error { } func less(left, right Entry) bool { - if left.StartedAt != right.StartedAt { - return left.StartedAt > right.StartedAt + if left.StartedAt != nil || right.StartedAt != nil { + if left.StartedAt == nil { + return false + } + if right.StartedAt == nil { + return true + } + if *left.StartedAt != *right.StartedAt { + return *left.StartedAt > *right.StartedAt + } } if left.Provider != right.Provider { return left.Provider < right.Provider @@ -130,6 +142,10 @@ func less(left, right Entry) bool { return left.SessionID < right.SessionID } +func validTimestamp(value *string) bool { + return value == nil || (*value != "" && len(*value) <= 128) +} + func Parse(data []byte) (Document, error) { var document Document if err := strictjson.Decode(data, &document); err != nil { diff --git a/internal/sessionindex/validate_test.go b/internal/sessionindex/validate_test.go index cc13e5d..712f9d4 100644 --- a/internal/sessionindex/validate_test.go +++ b/internal/sessionindex/validate_test.go @@ -1,6 +1,7 @@ package sessionindex import ( + "encoding/json" "math" "os" "testing" @@ -29,7 +30,7 @@ func TestValidateRejectsCoverageAdditionOverflow(t *testing.T) { func TestValidateIdentityUsesProviderAndSessionIDPair(t *testing.T) { d := minimumDocument() - d.Sessions = []Entry{{Provider: "claude", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: "now", EndedAt: "now"}, {Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: "now", EndedAt: "now"}} + d.Sessions = []Entry{{Provider: "claude", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: strptr("now"), EndedAt: strptr("now")}, {Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: strptr("now"), EndedAt: strptr("now")}} d.Coverage.Total = 2 d.Coverage.Complete = 2 d.Coverage.SourceAvailable = 2 @@ -52,6 +53,33 @@ func TestValidateCoverageAndDigest(t *testing.T) { } } +func TestParseAcceptsUnknownTimestampsAndCountsOnlyKnownValues(t *testing.T) { + body, err := os.ReadFile("../../testdata/contracts/v4/session-index-v1.unknown.valid.json") + if err != nil { + t.Fatal(err) + } + document, err := Parse(body) + if err != nil { + t.Fatalf("unknown timestamps were rejected: %v", err) + } + if document.Coverage.StartedAtKnown != 1 || document.Coverage.EndedAtKnown != 1 { + t.Fatalf("unknown timestamps were counted as known: %+v", document.Coverage) + } + + var raw map[string]any + if err := json.Unmarshal(body, &raw); err != nil { + t.Fatal(err) + } + raw["coverage"].(map[string]any)["started_at_known"] = float64(2) + malformed, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + if _, err := Parse(malformed); err == nil { + t.Fatal("accepted coverage that counted a null timestamp as known") + } +} + func TestParseRejectsFrozenInvalidFixture(t *testing.T) { b, err := os.ReadFile("../../testdata/contracts/v4/session-index-v1.invalid.json") if err != nil { @@ -107,7 +135,7 @@ func TestRenderCalculatesDigestAndIsDeterministic(t *testing.T) { func oneSessionDocument() Document { d := minimumDocument() - d.Sessions = []Entry{{Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, StateReasonCodes: []string{}, SourceAvailability: "available", StartedAt: "now", EndedAt: "now", Coverage: Coverage{}}} + d.Sessions = []Entry{{Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, StateReasonCodes: []string{}, SourceAvailability: "available", StartedAt: strptr("now"), EndedAt: strptr("now"), Coverage: Coverage{}}} d.Coverage = IndexCoverage{Total: 1, Complete: 1, SourceAvailable: 1, StartedAtKnown: 1, EndedAtKnown: 1} return d } diff --git a/internal/syncproject/migration.go b/internal/syncproject/migration.go index 020d784..72d4140 100644 --- a/internal/syncproject/migration.go +++ b/internal/syncproject/migration.go @@ -218,8 +218,16 @@ func migrationSessionIndex(store *memorystore.Store, manifest memory.GenerationM addIndexCoverage(&coverage, entry) } sort.Slice(entries, func(i, j int) bool { - if entries[i].StartedAt != entries[j].StartedAt { - return entries[i].StartedAt > entries[j].StartedAt + if entries[i].StartedAt != nil || entries[j].StartedAt != nil { + if entries[i].StartedAt == nil { + return false + } + if entries[j].StartedAt == nil { + return true + } + if *entries[i].StartedAt != *entries[j].StartedAt { + return *entries[i].StartedAt > *entries[j].StartedAt + } } if entries[i].Provider != entries[j].Provider { return entries[i].Provider < entries[j].Provider @@ -287,7 +295,7 @@ func migrationIndexEntry(view memory.SessionView, dependency memory.SessionViewD return sessionindex.Entry{ Provider: view.Provider, SessionID: view.SessionID, ProcessingState: state, StateReasonCodes: reasons, SourceAvailability: availability, SourceTerminalState: &terminal, - StartedAt: view.StartedAt, EndedAt: view.EndedAt, DurationMS: duration, + StartedAt: nullableTimestamp(view.StartedAt), EndedAt: nullableTimestamp(view.EndedAt), DurationMS: duration, WarningCount: uint64(len(view.Diagnostics)), RecordCount: &recordCount, IndexedEventCount: entryCoverage.Indexed, Coverage: entryCoverage, FactCounts: facts, SessionViewDigest: &sessionDigest, UsageRecordDigest: &usageDigest, @@ -305,6 +313,13 @@ func migrationDuration(startedAt, endedAt string) *uint64 { return &value } +func nullableTimestamp(value string) *string { + if value == "" { + return nil + } + return &value +} + func addIndexCoverage(coverage *sessionindex.IndexCoverage, entry sessionindex.Entry) { switch entry.ProcessingState { case sessionindex.ProcessingComplete: @@ -321,8 +336,12 @@ func addIndexCoverage(coverage *sessionindex.IndexCoverage, entry sessionindex.E } else { coverage.SourceUnavailable++ } - coverage.StartedAtKnown++ - coverage.EndedAtKnown++ + if entry.StartedAt != nil { + coverage.StartedAtKnown++ + } + if entry.EndedAt != nil { + coverage.EndedAtKnown++ + } if entry.UsageRecordDigest != nil { coverage.UsageKnown++ } diff --git a/internal/syncproject/service_test.go b/internal/syncproject/service_test.go index 464177d..aeeaa8a 100644 --- a/internal/syncproject/service_test.go +++ b/internal/syncproject/service_test.go @@ -23,6 +23,7 @@ import ( "github.com/neomei/SessionReviewer/internal/project" "github.com/neomei/SessionReviewer/internal/publicationlock" "github.com/neomei/SessionReviewer/internal/reviewv2" + "github.com/neomei/SessionReviewer/internal/sessionindex" syncengine "github.com/neomei/SessionReviewer/internal/sync" ) @@ -170,6 +171,26 @@ func TestSyncProjectBuildsBoundMigrationFromPreparedGeneration(t *testing.T) { } } +func TestMigrationSessionIndexPreservesUnknownTimestamps(t *testing.T) { + digest := "sha256:" + strings.Repeat("1", 64) + entry, err := migrationIndexEntry(memory.SessionView{ + Provider: "codex", SessionID: "unknown-times", TerminalState: memory.Indexed, + SourceAvailability: memory.SourceAvailable, UsageRecordDigest: digest, + ObservationSummaries: []memory.ObservationSummary{}, ActiveRevisionIDs: []string{}, Diagnostics: []memory.Diagnostic{}, + }, memory.SessionViewDependency{Digest: digest}, "generation-1") + if err != nil { + t.Fatal(err) + } + if entry.StartedAt != nil || entry.EndedAt != nil || entry.DurationMS != nil { + t.Fatalf("migration fabricated unknown timestamps: %+v", entry) + } + coverage := sessionindex.IndexCoverage{Total: 1} + addIndexCoverage(&coverage, entry) + if coverage.StartedAtKnown != 0 || coverage.EndedAtKnown != 0 { + t.Fatalf("migration counted unknown timestamps as known: %+v", coverage) + } +} + type migrationServiceFixture struct { projectID string project string diff --git a/obsidian-plugin/src/contracts/review-v4.ts b/obsidian-plugin/src/contracts/review-v4.ts index 5b95ca8..82fa73d 100644 --- a/obsidian-plugin/src/contracts/review-v4.ts +++ b/obsidian-plugin/src/contracts/review-v4.ts @@ -4,7 +4,7 @@ export type SessionIdentity = Readonly<{ provider: string; sessionId: string }>; export type ProcessingState = "complete" | "partial" | "error" | "unprocessed"; export type SourceAvailability = "available" | "unavailable"; -export type DecisionStatus = "active" | "superseded" | "archived"; +export type DecisionStatus = "active" | "superseded" | "archived" | "legacy_unmapped"; export type CandidateStatus = "pending" | "confirmed" | "ignored" | "not_decision" | "stale"; export type PriceStatus = | "pending" @@ -108,6 +108,7 @@ export interface DecisionV4 { rationale: string; impact: string; status: DecisionStatus; + legacy_status_text: string | null; reevaluate_when: string; supersedes: string[]; milestone_ids: string[]; @@ -335,8 +336,8 @@ export interface SessionIndexEntryV1 { state_reason_codes: SessionStateReasonCodeV1[]; source_availability: SourceAvailability; source_terminal_state: string | null; - started_at: string; - ended_at: string; + started_at: string | null; + ended_at: string | null; duration_ms: number | null; warning_count: number; record_count: number | null; diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index 64d770d..1fe1411 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -225,14 +225,53 @@ function parseMachineLedgerDocument(source: string): MachineLedgerV4 { pricingByID.set(snapshot.snapshot_id, snapshot); } const currentIDs = idArray(row.current_pricing_snapshot_ids, "$.current_pricing_snapshot_ids", 65536); + const pricingIdentity = (snapshot: PricingSnapshotV1): string => + identityKey(snapshot.provider, `${snapshot.session_id}\u0000${snapshot.usage_record_digest}`); + const successorCounts = new Map(); + for (const [snapshotID, snapshot] of pricingByID) { + const predecessorID = snapshot.supersedes_snapshot_id; + if (predecessorID === null) continue; + const predecessor = pricingByID.get(predecessorID); + if (!predecessor) throw new Error(`pricing snapshot "${snapshotID}" has missing predecessor "${predecessorID}"`); + if (predecessorID === snapshotID) throw new Error("pricing snapshot cannot supersede itself"); + if (pricingIdentity(predecessor) !== pricingIdentity(snapshot)) throw new Error("pricing predecessor and successor identity mismatch"); + const successors = (successorCounts.get(predecessorID) ?? 0) + 1; + if (successors > 1) throw new Error("pricing supersession graph branches into multiple leaves"); + successorCounts.set(predecessorID, successors); + } + const graphState = new Map(); + const visitPricing = (snapshotID: string): void => { + if (graphState.get(snapshotID) === 1) throw new Error("pricing supersession graph contains cycle"); + if (graphState.get(snapshotID) === 2) return; + graphState.set(snapshotID, 1); + const predecessorID = pricingByID.get(snapshotID)?.supersedes_snapshot_id; + if (predecessorID !== null && predecessorID !== undefined) visitPricing(predecessorID); + graphState.set(snapshotID, 2); + }; + for (const snapshotID of pricingByID.keys()) visitPricing(snapshotID); + for (const [snapshotID, snapshot] of pricingByID) { + if (((successorCounts.get(snapshotID) ?? 0) > 0) !== (snapshot.status === "superseded")) { + throw new Error("pricing supersession status does not match graph position"); + } + } const seenCurrent = new Set(); + const currentByIdentity = new Map(); let currentPricingIncomplete = false; for (const snapshotID of currentIDs) { addUnique(seenCurrent, snapshotID, "current pricing snapshot reference"); const snapshot = pricingByID.get(snapshotID); - if (!snapshot || snapshot.status === "superseded") throw new Error("invalid current pricing snapshot reference"); + if (!snapshot || snapshot.status === "superseded" || (successorCounts.get(snapshotID) ?? 0) !== 0) throw new Error("invalid current pricing snapshot reference"); + const identity = pricingIdentity(snapshot); + if (currentByIdentity.has(identity)) throw new Error("multiple current pricing snapshots for one usage record"); + currentByIdentity.set(identity, snapshotID); currentPricingIncomplete ||= !snapshot.pricing_complete; } + for (const [snapshotID, snapshot] of pricingByID) { + const selected = currentByIdentity.get(pricingIdentity(snapshot)); + if (selected !== undefined && (successorCounts.get(snapshotID) ?? 0) === 0 && selected !== snapshotID) { + throw new Error("multiple effective pricing leaves for one usage record"); + } + } if (currentPricingIncomplete && accounting.total_cost_usd !== null) { throw new Error("aggregate price must be null when a current snapshot is incomplete"); } @@ -294,8 +333,8 @@ function parseSessionIndexDocument(source: string): SessionIndexV1 { calculated[session.processing_state] += 1; if (session.source_availability === "available") calculated.source_available += 1; else calculated.source_unavailable += 1; - calculated.started_at_known += 1; - calculated.ended_at_known += 1; + if (session.started_at !== null) calculated.started_at_known += 1; + if (session.ended_at !== null) calculated.ended_at_known += 1; if (session.usage_record_digest !== null) calculated.usage_known += 1; parsedSessions.push(session); } @@ -606,7 +645,7 @@ function parseTimeline(value: unknown, path: string, generationID: string): Time function parseDecision(value: unknown, path: string): DecisionV4 { const row = object(value, path); exact(row, path, [ - "id", "kind", "occurred_at", "title", "rationale", "impact", "status", "reevaluate_when", "supersedes", + "id", "kind", "occurred_at", "title", "rationale", "impact", "status", "legacy_status_text", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision" ]); id(row.id, `${path}.id`); @@ -615,7 +654,8 @@ function parseDecision(value: unknown, path: string): DecisionV4 { text(row.title, `${path}.title`, 16384); text(row.rationale, `${path}.rationale`, 16384); text(row.impact, `${path}.impact`, 16384); - oneOf(row.status, `${path}.status`, ["active", "superseded", "archived"]); + const status = oneOf(row.status, `${path}.status`, ["active", "superseded", "archived", "legacy_unmapped"]); + nullableText(row.legacy_status_text, `${path}.legacy_status_text`, 16384); text(row.reevaluate_when, `${path}.reevaluate_when`, 16384); idArray(row.supersedes, `${path}.supersedes`, 256, true); idArray(row.milestone_ids, `${path}.milestone_ids`, 256, true); @@ -625,7 +665,12 @@ function parseDecision(value: unknown, path: string): DecisionV4 { const ref = parseSessionReference(refs[index], `${path}.session_refs[${index}]`); addUnique(identities, identityKey(ref.provider, ref.session_id), "decision session reference"); } - oneOf(row.provenance, `${path}.provenance`, ["human_created", "migrated", "ai_candidate_confirmed"]); + const provenance = oneOf(row.provenance, `${path}.provenance`, ["human_created", "migrated", "ai_candidate_confirmed"]); + if (status === "legacy_unmapped") { + if (row.legacy_status_text === null || provenance !== "migrated") throw new Error(`${path} legacy unmapped status requires migrated provenance and exact status text`); + } else if (row.legacy_status_text !== null) { + throw new Error(`${path} native decision status cannot carry legacy status text`); + } boolean(row.pinned, `${path}.pinned`); positiveInteger(row.revision, `${path}.revision`); return row as unknown as DecisionV4; @@ -946,8 +991,8 @@ function parseIndexEntry(value: unknown, path: string): SessionIndexEntryV1 { } oneOf(row.source_availability, `${path}.source_availability`, ["available", "unavailable"]); nullableText(row.source_terminal_state, `${path}.source_terminal_state`, 64); - text(row.started_at, `${path}.started_at`, 128, true); - text(row.ended_at, `${path}.ended_at`, 128, true); + if (row.started_at !== null) text(row.started_at, `${path}.started_at`, 128, true); + if (row.ended_at !== null) text(row.ended_at, `${path}.ended_at`, 128, true); nullableInteger(row.duration_ms, `${path}.duration_ms`); integer(row.warning_count, `${path}.warning_count`); nullableInteger(row.record_count, `${path}.record_count`); @@ -1534,8 +1579,12 @@ function compareGoStrings(left: string, right: string): number { } function compareIndexEntries(left: SessionIndexEntryV1, right: SessionIndexEntryV1): number { - const started = compareGoStrings(right.started_at, left.started_at); - if (started !== 0) return started; + if (left.started_at !== null || right.started_at !== null) { + if (left.started_at === null) return 1; + if (right.started_at === null) return -1; + const started = compareGoStrings(right.started_at, left.started_at); + if (started !== 0) return started; + } const provider = compareGoStrings(left.provider, right.provider); return provider !== 0 ? provider : compareGoStrings(left.session_id, right.session_id); } diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index 7cab16d..7601763 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -75,6 +75,7 @@ function decision(id: string, supersedes: string[], status = "active"): JsonObje rationale: "reason", impact: "impact", status, + legacy_status_text: null, reevaluate_when: "later", supersedes, milestone_ids: [], @@ -290,6 +291,20 @@ describe("session contracts", () => { expect(() => parseSessionIndexV1(JSON.stringify(index))).toThrow(/duplicate/i); }); + it("accepts null session timestamps, counts only known values, and sorts null last", async () => { + const source = await pluginFixture("session-index-v1.unknown.valid.json"); + const parsed = parseSessionIndexV1(source); + expect(parsed.coverage.started_at_known).toBe(1); + expect(parsed.coverage.ended_at_known).toBe(1); + expect(parsed.sessions.map((session) => session.started_at)).toEqual(["2026-09-04T00:00:00Z", null]); + await expect(readFile(resolve(here, "fixtures/v4/session-index-v1.unknown.valid.json"))) + .resolves.toEqual(await sharedFixture("session-index-v1.unknown.valid.json")); + + const malformed = JSON.parse(source) as { coverage: JsonObject }; + malformed.coverage.started_at_known = 2; + expect(() => parseSessionIndexV1(JSON.stringify(malformed))).toThrow(/coverage|known|reconcile/i); + }); + it("rejects mixed project, generation, project digest, and index digest bindings", async () => { const { ledger, index } = await nonzeroBoundSnapshots(); expect(() => assertSnapshotBindings(ledger, index)).not.toThrow(); @@ -325,6 +340,21 @@ describe("session contracts", () => { expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/cycle/i); }); + it("accepts only the lossless legacy decision status representation", async () => { + const review = await fixtureObject("review-presentation-v4.valid.json") as { decisions: JsonObject[] }; + const legacy = decision("legacy", [], "legacy_unmapped"); + legacy.provenance = "migrated"; + legacy.legacy_status_text = "已采用"; + review.decisions = [legacy]; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).not.toThrow(); + + legacy.legacy_status_text = null; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/legacy|status|text/i); + legacy.legacy_status_text = "已采用"; + legacy.status = "active"; + expect(() => parseReviewPresentationV4(JSON.stringify(review))).toThrow(/legacy|status|text/i); + }); + it("rejects cursors on a zero-total event page", async () => { const page = await fixtureObject("session-event-page-v1.valid.json"); page.previous_cursor = "cursor"; @@ -491,7 +521,9 @@ describe("pricing and optional-field semantics", () => { current_pricing_snapshot_ids: string[]; }; const complete = clone(ledger.pricing_snapshots[0]); + ledger.pricing_snapshots[0].status = "superseded"; complete.snapshot_id = "snapshot-current"; + complete.supersedes_snapshot_id = ledger.pricing_snapshots[0].snapshot_id; complete.pricing_complete = true; complete.rates = { input: 0, cached_input: 0, cache_write_input: 0, output: 0, reasoning_output: 0 }; complete.line_costs_usd = { input: 0, cached_input: 0, cache_write_input: 0, output: 0, reasoning_output: 0 }; @@ -505,7 +537,48 @@ describe("pricing and optional-field semantics", () => { expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).not.toThrow(); ledger.current_pricing_snapshot_ids = ["snapshot-1"]; - expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).toThrow(/aggregate|incomplete|null/i); + expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).toThrow(/aggregate|incomplete|null|current/i); + }); + + it("enforces a single identity-bound current leaf in each pricing history", async () => { + const ledger = await fixtureObject("machine-ledger-v4.valid.json") as { + pricing_snapshots: JsonObject[]; + current_pricing_snapshot_ids: string[]; + }; + const predecessor = clone(ledger.pricing_snapshots[0]); + predecessor.status = "superseded"; + const successor = clone(ledger.pricing_snapshots[0]); + successor.snapshot_id = "snapshot-successor"; + successor.supersedes_snapshot_id = predecessor.snapshot_id; + ledger.pricing_snapshots = [predecessor, successor]; + ledger.current_pricing_snapshot_ids = [successor.snapshot_id as string]; + expect(() => parseMachineLedgerV4(JSON.stringify(ledger))).not.toThrow(); + + for (const testCase of [ + { name: "missing predecessor", mutate: (copy: typeof ledger) => { copy.pricing_snapshots[1].supersedes_snapshot_id = "missing"; } }, + { name: "self reference", mutate: (copy: typeof ledger) => { copy.pricing_snapshots[1].supersedes_snapshot_id = copy.pricing_snapshots[1].snapshot_id; } }, + { name: "cycle", mutate: (copy: typeof ledger) => { copy.pricing_snapshots[0].supersedes_snapshot_id = copy.pricing_snapshots[1].snapshot_id; } }, + { name: "identity mismatch", mutate: (copy: typeof ledger) => { copy.pricing_snapshots[1].session_id = "other-session"; } }, + { name: "provider mismatch", mutate: (copy: typeof ledger) => { copy.pricing_snapshots[1].provider = "claude"; } }, + { name: "usage record mismatch", mutate: (copy: typeof ledger) => { copy.pricing_snapshots[1].usage_record_digest = `sha256:${"2".repeat(64)}`; } }, + { name: "branching successors", mutate: (copy: typeof ledger) => { + const branch = clone(copy.pricing_snapshots[1]); + branch.snapshot_id = "snapshot-branch"; + copy.pricing_snapshots.push(branch); + } }, + { name: "non-leaf selected", mutate: (copy: typeof ledger) => { copy.pricing_snapshots[0].status = "current"; copy.current_pricing_snapshot_ids = [copy.pricing_snapshots[0].snapshot_id as string]; } }, + { name: "multiple effective leaves", mutate: (copy: typeof ledger) => { + const branch = clone(copy.pricing_snapshots[1]); + branch.snapshot_id = "snapshot-branch"; + branch.supersedes_snapshot_id = null; + copy.pricing_snapshots.push(branch); + } } + ] as const) { + const malformed = clone(ledger); + testCase.mutate(malformed); + expect(() => parseMachineLedgerV4(JSON.stringify(malformed)), testCase.name) + .toThrow(/pricing|snapshot|predecessor|cycle|identity|leaf|current|branch/i); + } }); it("preserves explicit empty optional arrays instead of erasing them", async () => { diff --git a/obsidian-plugin/tests/fixtures/v4/session-index-v1.unknown.valid.json b/obsidian-plugin/tests/fixtures/v4/session-index-v1.unknown.valid.json new file mode 100644 index 0000000..e926f8c --- /dev/null +++ b/obsidian-plugin/tests/fixtures/v4/session-index-v1.unknown.valid.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "generated_at": "2026-09-04T00:00:00Z", "sort_version": "started-at-desc-null-last-provider-session-v1", + "coverage": { "total": 2, "complete": 2, "partial": 0, "error": 0, "unprocessed": 0, "source_available": 2, "source_unavailable": 0, "started_at_known": 1, "ended_at_known": 1, "usage_known": 0 }, + "sessions": [ + { + "provider": "codex", "session_id": "known", "processing_state": "complete", "state_reason_codes": [], "source_availability": "available", "source_terminal_state": "indexed", + "started_at": "2026-09-04T00:00:00Z", "ended_at": "2026-09-04T00:01:00Z", "duration_ms": 60000, "warning_count": 0, "record_count": 1, "indexed_event_count": 1, + "coverage": { "seen": 1, "indexed": 1, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "fact_counts": { "file_change": 0, "command": 1, "verification": 0, "error": 0, "artifact": 0 }, + "session_view_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "usage_record_digest": null, "summary_digest": null, "last_seen_generation_id": "generation-1", "last_successful_generation_id": "generation-1" + }, + { + "provider": "claude", "session_id": "unknown", "processing_state": "complete", "state_reason_codes": [], "source_availability": "available", "source_terminal_state": "indexed", + "started_at": null, "ended_at": null, "duration_ms": null, "warning_count": 0, "record_count": 0, "indexed_event_count": 0, + "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "fact_counts": { "file_change": 0, "command": 0, "verification": 0, "error": 0, "artifact": 0 }, + "session_view_digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", "usage_record_digest": null, "summary_digest": null, "last_seen_generation_id": "generation-1", "last_successful_generation_id": "generation-1" + } + ] +} diff --git a/schemas/machine-ledger-v4.schema.json b/schemas/machine-ledger-v4.schema.json index 41792d1..ce76d4e 100644 --- a/schemas/machine-ledger-v4.schema.json +++ b/schemas/machine-ledger-v4.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://sessionreviewer.local/schemas/machine-ledger-v4.schema.json", "title": "SessionReviewer machine ledger v4", + "$comment": "Runtime validators additionally enforce a non-branching, acyclic pricing supersession graph, identical provider/session/usage identity across each edge, and at most one selected effective leaf per usage record.", "type": "object", "additionalProperties": false, "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "accepted_revision", "review_sha256", "history_sha256", "accounting", "sessions", "human_patches", "orphan_patches", "generated_baselines", "pricing_snapshots", "current_pricing_snapshot_ids", "sync_hashes"], "properties": { diff --git a/schemas/review-presentation-v4.schema.json b/schemas/review-presentation-v4.schema.json index bd07e84..f64b0bd 100644 --- a/schemas/review-presentation-v4.schema.json +++ b/schemas/review-presentation-v4.schema.json @@ -27,7 +27,7 @@ "closed_loop_coverage": { "type": "object", "additionalProperties": false, "required": ["source_turns", "captured_turns", "truncated_turns", "source_unavailable_turns"], "properties": { "source_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "captured_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "truncated_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "source_unavailable_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } } }, "closed_loop": { "type": "object", "additionalProperties": false, "required": ["trigger_question", "conclusion", "execution", "verification", "impact_and_follow_up", "source_turn_refs", "coverage"], "properties": { "trigger_question": { "$ref": "#/$defs/closed_loop_segment" }, "conclusion": { "$ref": "#/$defs/closed_loop_conclusion" }, "execution": { "$ref": "#/$defs/closed_loop_segment" }, "verification": { "$ref": "#/$defs/closed_loop_segment" }, "impact_and_follow_up": { "$ref": "#/$defs/closed_loop_segment" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" }, "coverage": { "$ref": "#/$defs/closed_loop_coverage" } } }, "timeline": { "type": "object", "additionalProperties": false, "required": ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids", "closed_loop"], "properties": { "id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "kind": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "summary": { "$ref": "#/$defs/text" }, "decision_ids": { "$ref": "#/$defs/id_array" }, "closed_loop": { "$ref": "#/$defs/closed_loop" } } }, - "decision": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "occurred_at", "title", "rationale", "impact", "status", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "kind": { "enum": ["decision", "agreement"] }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "title": { "$ref": "#/$defs/text" }, "rationale": { "$ref": "#/$defs/text" }, "impact": { "$ref": "#/$defs/text" }, "status": { "enum": ["active", "superseded", "archived"] }, "reevaluate_when": { "$ref": "#/$defs/text" }, "supersedes": { "$ref": "#/$defs/id_array" }, "milestone_ids": { "$ref": "#/$defs/id_array" }, "session_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/session_ref" } }, "provenance": { "enum": ["human_created", "migrated", "ai_candidate_confirmed"] }, "pinned": { "type": "boolean" }, "revision": { "type": "integer", "minimum": 1 } } }, + "decision": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "occurred_at", "title", "rationale", "impact", "status", "legacy_status_text", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "kind": { "enum": ["decision", "agreement"] }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "title": { "$ref": "#/$defs/text" }, "rationale": { "$ref": "#/$defs/text" }, "impact": { "$ref": "#/$defs/text" }, "status": { "enum": ["active", "superseded", "archived", "legacy_unmapped"] }, "legacy_status_text": { "type": ["string", "null"], "maxLength": 16384 }, "reevaluate_when": { "$ref": "#/$defs/text" }, "supersedes": { "$ref": "#/$defs/id_array" }, "milestone_ids": { "$ref": "#/$defs/id_array" }, "session_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/session_ref" } }, "provenance": { "enum": ["human_created", "migrated", "ai_candidate_confirmed"] }, "pinned": { "type": "boolean" }, "revision": { "type": "integer", "minimum": 1 } }, "allOf": [{ "if": { "properties": { "status": { "const": "legacy_unmapped" } }, "required": ["status"] }, "then": { "properties": { "legacy_status_text": { "type": "string" }, "provenance": { "const": "migrated" } } }, "else": { "properties": { "legacy_status_text": { "type": "null" } } } }] }, "session_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" } } }, "risk": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "detail"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "detail": { "$ref": "#/$defs/text" } } }, "open_loop": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "question", "next_experiment", "completion_criterion"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "question": { "$ref": "#/$defs/text" }, "next_experiment": { "$ref": "#/$defs/text" }, "completion_criterion": { "$ref": "#/$defs/text" } } }, diff --git a/schemas/session-index-v1.schema.json b/schemas/session-index-v1.schema.json index 4fce793..c32e444 100644 --- a/schemas/session-index-v1.schema.json +++ b/schemas/session-index-v1.schema.json @@ -44,7 +44,7 @@ "state_reason_codes": { "type": "array", "maxItems": 64, "items": { "enum": ["not_discovered", "duplicate_candidate", "freeze_terminal", "malformed_source_records", "unsupported_source_records", "source_missing", "source_unreadable", "source_ambiguous", "source_unsupported", "source_unavailable", "partial_observations", "unprojected_facts", "undecodable_facts", "scan_cancelled"] } }, "source_availability": { "enum": ["available", "unavailable"] }, "source_terminal_state": { "type": ["string", "null"], "maxLength": 64 }, - "started_at": { "$ref": "#/$defs/timestamp" }, "ended_at": { "$ref": "#/$defs/timestamp" }, + "started_at": { "anyOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }] }, "ended_at": { "anyOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }] }, "duration_ms": { "type": ["integer", "null"], "minimum": 0 }, "warning_count": { "$ref": "#/$defs/nonnegative" }, "record_count": { "type": ["integer", "null"], "minimum": 0 }, "indexed_event_count": { "$ref": "#/$defs/nonnegative" }, "coverage": { "$ref": "#/$defs/session_coverage" }, "fact_counts": { "$ref": "#/$defs/fact_counts" }, diff --git a/testdata/contracts/v4/session-index-v1.unknown.valid.json b/testdata/contracts/v4/session-index-v1.unknown.valid.json new file mode 100644 index 0000000..e926f8c --- /dev/null +++ b/testdata/contracts/v4/session-index-v1.unknown.valid.json @@ -0,0 +1,18 @@ +{ + "schema_version": 1, "minimum_reader_version": "0.4.0", "digest": "sha256:0000000000000000000000000000000000000000000000000000000000000000", "project_id": "project-p", "generation_id": "generation-1", "project_view_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "generated_at": "2026-09-04T00:00:00Z", "sort_version": "started-at-desc-null-last-provider-session-v1", + "coverage": { "total": 2, "complete": 2, "partial": 0, "error": 0, "unprocessed": 0, "source_available": 2, "source_unavailable": 0, "started_at_known": 1, "ended_at_known": 1, "usage_known": 0 }, + "sessions": [ + { + "provider": "codex", "session_id": "known", "processing_state": "complete", "state_reason_codes": [], "source_availability": "available", "source_terminal_state": "indexed", + "started_at": "2026-09-04T00:00:00Z", "ended_at": "2026-09-04T00:01:00Z", "duration_ms": 60000, "warning_count": 0, "record_count": 1, "indexed_event_count": 1, + "coverage": { "seen": 1, "indexed": 1, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "fact_counts": { "file_change": 0, "command": 1, "verification": 0, "error": 0, "artifact": 0 }, + "session_view_digest": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "usage_record_digest": null, "summary_digest": null, "last_seen_generation_id": "generation-1", "last_successful_generation_id": "generation-1" + }, + { + "provider": "claude", "session_id": "unknown", "processing_state": "complete", "state_reason_codes": [], "source_availability": "available", "source_terminal_state": "indexed", + "started_at": null, "ended_at": null, "duration_ms": null, "warning_count": 0, "record_count": 0, "indexed_event_count": 0, + "coverage": { "seen": 0, "indexed": 0, "collapsed": 0, "unprojected": 0, "undecodable": 0, "truncated": 0 }, "fact_counts": { "file_change": 0, "command": 0, "verification": 0, "error": 0, "artifact": 0 }, + "session_view_digest": "sha256:3333333333333333333333333333333333333333333333333333333333333333", "usage_record_digest": null, "summary_digest": null, "last_seen_generation_id": "generation-1", "last_successful_generation_id": "generation-1" + } + ] +} From 1d4300ec2538690f1c33d77df4d1a0c9df1c246a Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 03:00:44 +0800 Subject: [PATCH 21/25] docs: record final Gate 0 fix evidence --- docs/session-review/gate-0-evidence.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/session-review/gate-0-evidence.md b/docs/session-review/gate-0-evidence.md index 2c83587..12b9c56 100644 --- a/docs/session-review/gate-0-evidence.md +++ b/docs/session-review/gate-0-evidence.md @@ -4,7 +4,7 @@ **LOCAL COMPLETE / WINDOWS CI PENDING** -本地 Gate 0 已在实现边界提交 `b30876db1d61026eb52f5d6d6533c052fd7a93b7` 上重新通过。合同矩阵现已覆盖 `conversation-chain-v1`、`problem-map-candidate-v1`、正式问题图、演进闭环和通用 Agent annotation,并完成了 Fix Round 1 的五项跨语言契约修正。该提交未推送,也没有原生 Windows 运行,因此 Windows CI 证据仍为 PENDING。 +本地 Gate 0 已在最终实现提交 `d81e68a9fe4bc656ce5c29cb7369a4924a2d3a7b` 上重新通过。最终修正同步封闭了 Session 时间未知态、v3 人类决策状态无损迁移和价格替代图完整性三项 Important 问题。该提交未推送,也没有原生 Windows 运行,因此 Windows CI 证据仍为 PENDING。 ## 审计对象 @@ -12,8 +12,10 @@ - 任务基准:`6421a9aad6d7a65bbaef2fa71e4d7e7be3431db6` - 初始实现提交:`e3ff49beb6cb28d4aacb73a5ba4f45c43289b112` - Fix Round 1 实现提交:`b30876db1d61026eb52f5d6d6533c052fd7a93b7` +- Final fix 实现提交:`d81e68a9fe4bc656ce5c29cb7369a4924a2d3a7b` - 环境:`Darwin arm64`,`go1.26.5 darwin/arm64`,Node `v24.18.0`,npm `11.16.0` - Fix Round 1 提交统计:18 files changed,279 insertions,39 deletions +- Final fix 提交统计:20 files changed,628 insertions,72 deletions - 未纳入任务提交:既有未跟踪目录 `.superpowers/brainstorm/` ## TDD 边界 @@ -30,17 +32,19 @@ go test ./internal/conversationchain ./internal/problemmap ./internal/reviewv4 - Fix Round 1 先新增回归测试并获得预期 RED:CLI 编译报告 `ParseProblemContractWithInput` 未定义;Go 分别证明零 digest、超过 JavaScript safe integer 上限以及 schema 的非空图 revision-zero 会被错误接受;TypeScript 为 2 failed / 57 passed。修正后聚焦 Go 五个 package 全部 PASS,`contracts-v4.test.ts` 为 59/59 PASS。完整 RED/GREEN 原始输出保存在 Task 7 report 的 `Fix Round 1` 节。 +Final fix 先分别获得聚焦 RED:Go Session index 拒绝 `started_at:null`;v3 `已采用` 状态报告无精确 v4 mapping,v4 reader 拒绝未知的 `legacy_status_text`;整本账本验证错误接受缺失前驱、自指、环、身份不符和多有效叶子。TypeScript 对应为 3 failed / 59 skipped。修正后聚焦 Go `sessionindex/reviewv4/migrationv4/syncproject/memory` 全部 PASS,`contracts-v4.test.ts` 为 62/62 PASS。完整 RED/GREEN 原始输出保存在 final-fix report。 + ## 完整本地门禁 按串行顺序执行: | 命令 | 结果 | 证据 | |---|---|---| -| `gofmt -w internal/conversationchain internal/problemmap internal/reviewv4 internal/cli` | PASS | 无输出 | -| `go test -p 1 -timeout 5m -count=1 ./...` | PASS | Fix Round 1 后全 package 重跑;较慢 package 包括 `internal/scan` 114.532s、`internal/reviewjob` 55.855s、`test/zerotoken` 34.961s,均低于每个测试二进制 5 分钟超时 | +| `gofmt -w` 所有变更的 Go 文件 | PASS | `gofmt` 后聚焦 Go 包和完整串行门禁均通过 | +| `go test -p 1 -timeout 5m -count=1 ./...` | PASS | 在 Final fix 精确提交树上全 package 重跑;较慢 package 包括 `internal/scan` 221.908s、`internal/reviewjob` 119.880s、`test/zerotoken` 103.223s、`internal/apply` 101.111s,均低于每个测试二进制 5 分钟超时 | | `go vet ./...` | PASS | exit 0,无输出 | | `go mod tidy -diff` | PASS | exit 0,无 diff | -| `cd obsidian-plugin && npm run check` | PASS | lint;17/17 test files、123/123 tests;TypeScript typecheck;production bundle | +| `cd obsidian-plugin && npm run check` | PASS | lint;17/17 test files、126/126 tests;TypeScript typecheck;production bundle | | `git diff --check` | PASS | exit 0,无输出 | 独立 ordinary-flow 复核 `go test ./test/zerotoken -count=1 -run 'TestGate(A|B)' -v` PASS:Gate A 154/154 terminal、151 indexed、zero model tokens;Gate B 端到端发布与幂等测试通过。新增 deterministic candidate fixture 明确要求 `agent_run_id=null`,本任务没有启动或实现 Agent 执行。 @@ -62,10 +66,14 @@ Fix Round 1 先新增回归测试并获得预期 RED:CLI 编译报告 `ParsePr | conversation-chain-v1 | `wire_contract_invalid` | | problem-map-candidate-v1 | `wire_contract_invalid` | -独立遍历 `testdata/contracts/v4/*.json` 并对同名插件 fixture 执行 `cmp -s`,结果为 **20/20 Go/plugin fixture files byte-identical**。 +独立遍历 `testdata/contracts/v4/*.json` 并对同名插件 fixture 执行 `cmp -s`,结果为 **21/21 Go/plugin fixture files byte-identical**。 ## 扩展合同与迁移边界 +- `session-index-v1.started_at/ended_at` 现为显式可空;Go/schema/TypeScript 只把非 `null` 值计入 known coverage,并使用 null-last 规范排序。迁移投影把缺失时间写为 `null`,不补造时间。 +- v4 Decision 新增封闭 `legacy_unmapped` 兼容状态和必填可空字段 `legacy_status_text`;兼容状态要求该字段保存精确原文且 `provenance=migrated`,原生 v4 状态要求它为 `null`。v3 只对精确 `active/archived` 做同名映射,其他文本不做语义猜测。 +- `machine-ledger-v4` 的 Go/TypeScript 整本账本验证现在要求价格前驱存在、非自指、无环、无分叉、边两端用量身份一致,且每条用量最多一个被选中的有效叶子。历史快照仍不可变,未知成本仍为 `null`。 + - 会话身份始终为 `(provider, session_id)`;conversation chain 只允许 user/assistant 可见 excerpt,4,096 UTF-8 bytes 上限,并绑定认证 source refs。 - 两个新增自摘要合同只省略各自的 `digest` 字段计算 canonical digest;valid fixtures 使用非零 digest,tamper tests 同时覆盖 Go/TypeScript。 - 正式问题图只存在于 `review-presentation-v4`;验证 parent/relation 存在、无环、每组 sibling order 唯一稳定、related/alternate 最多两个。 From 9d1c6d66f30796be5895a4797ada8c88e9148ed7 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 10:13:06 +0800 Subject: [PATCH 22/25] fix: close v4 contract type and integer gaps --- internal/annotation/validate.go | 4 +- internal/annotation/validate_test.go | 8 ++++ internal/inspect/validate.go | 17 +++++--- internal/inspect/validate_test.go | 38 ++++++++++++++++++ internal/memory/api_compat_test.go | 44 +++++++++++++++++++++ internal/pricing/validate.go | 12 ++++-- internal/pricing/validate_test.go | 13 +++++++ internal/reviewv4/codec_test.go | 45 ++++++++++++++++++++++ internal/reviewv4/validate.go | 12 +++--- internal/sessionindex/validate.go | 36 +++++++++++++++++ internal/sessionindex/validate_test.go | 26 +++++++++++++ obsidian-plugin/src/contracts/review-v4.ts | 12 ++++-- obsidian-plugin/tests/contracts-v4.test.ts | 13 ++++++- schemas/agent-annotation-v1.schema.json | 2 +- schemas/machine-ledger-v4.schema.json | 4 +- schemas/pricing-snapshot-v1.schema.json | 2 +- schemas/review-presentation-v4.schema.json | 4 +- schemas/session-event-page-v1.schema.json | 4 +- schemas/session-index-v1.schema.json | 6 +-- schemas/session-summary-v1.schema.json | 6 +-- 20 files changed, 271 insertions(+), 37 deletions(-) diff --git a/internal/annotation/validate.go b/internal/annotation/validate.go index 5c23692..6ac7c1b 100644 --- a/internal/annotation/validate.go +++ b/internal/annotation/validate.go @@ -12,6 +12,8 @@ import ( var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +const maxWireInteger = 1<<53 - 1 + func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } func validText(value string, maximum int) bool { return len(value) <= maximum } @@ -46,7 +48,7 @@ func Validate(store StoreRecord) error { } annotations := make(map[string]struct{}, len(store.Annotations)) for index, annotation := range store.Annotations { - if annotation.SchemaVersion != 1 || annotation.ProjectID != store.ProjectID || !validID(annotation.ID) || !validID(annotation.GenerationID) || !validID(annotation.AnalysisProfile) || !validID(annotation.AgentRunID) || !validText(annotation.Text, 4096) || annotation.Revision < 1 || !validText(annotation.CreatedAt, 128) || len(annotation.Dependencies) > 256 { + if annotation.SchemaVersion != 1 || annotation.ProjectID != store.ProjectID || !validID(annotation.ID) || !validID(annotation.GenerationID) || !validID(annotation.AnalysisProfile) || !validID(annotation.AgentRunID) || !validText(annotation.Text, 4096) || annotation.Revision < 1 || annotation.Revision > maxWireInteger || !validText(annotation.CreatedAt, 128) || len(annotation.Dependencies) > 256 { return fmt.Errorf("invalid annotation %d", index) } if _, exists := annotations[annotation.ID]; exists { diff --git a/internal/annotation/validate_test.go b/internal/annotation/validate_test.go index d68e5ac..5cec2c4 100644 --- a/internal/annotation/validate_test.go +++ b/internal/annotation/validate_test.go @@ -24,6 +24,14 @@ func TestValidateStoreRecordRequiresProjectIdentity(t *testing.T) { } } +func TestValidateRejectsRevisionAboveJavaScriptSafeMaximum(t *testing.T) { + entityID, field := "e", "f" + store := StoreRecord{SchemaVersion: 1, MinimumReaderVersion: "0.4.0", ProjectID: "p", Annotations: []Annotation{{ID: "a", ProjectID: "p", AnnotationKind: "decision_candidate", EntityID: &entityID, Field: &field, Status: "pending", Text: "candidate", GenerationID: "g", SchemaVersion: 1, AnalysisProfile: "profile", AgentRunID: "run", Dependencies: []Dependency{}, Revision: 1 << 53, CreatedAt: "now"}}, ExtractionRuns: []Run{{RunID: "run", ProjectID: "p", Status: "completed", ExtractorVersion: "v1", PromptSchemaVersion: "v1", DependencyDigests: []string{}, CreatedAt: "now", UpdatedAt: "now"}}} + if err := Validate(store); err == nil { + t.Fatal("accepted revision above the JavaScript safe maximum") + } +} + func TestParseRejectsFrozenInvalidFixture(t *testing.T) { b, err := os.ReadFile("../../testdata/contracts/v4/agent-annotation-v1.invalid.json") if err != nil { diff --git a/internal/inspect/validate.go b/internal/inspect/validate.go index e56c8bc..ec2bb9c 100644 --- a/internal/inspect/validate.go +++ b/internal/inspect/validate.go @@ -13,6 +13,8 @@ import ( var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +const maxWireInteger uint64 = 1<<53 - 1 + var eventKinds = map[string]bool{ "message": true, "tool_call": true, "tool_result": true, "cwd_change": true, "usage": true, "skip": true, "file_change": true, "command": true, @@ -23,12 +25,15 @@ func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(v func validCoverage(coverage Coverage) bool { total := uint64(0) for _, value := range []uint64{coverage.Indexed, coverage.Collapsed, coverage.Unprojected, coverage.Undecodable, coverage.Truncated} { + if value > maxWireInteger { + return false + } if ^uint64(0)-total < value { return false } total += value } - return total == coverage.Seen + return coverage.Seen <= maxWireInteger && total == coverage.Seen } func validateIdentity(schemaVersion int, reader, project, provider, session, generation, digest string) error { @@ -39,7 +44,7 @@ func validateIdentity(schemaVersion int, reader, project, provider, session, gen } func validateEntry(entry Entry) error { - if len(entry.OccurredAt) > 128 || entry.Sequence == 0 || !validID(entry.RevisionID) || len(entry.Text) > 512 || len(entry.SourceRevisionIDs) > 64 { + if len(entry.OccurredAt) > 128 || entry.Sequence == 0 || entry.Sequence > maxWireInteger || !validID(entry.RevisionID) || len(entry.Text) > 512 || len(entry.SourceRevisionIDs) > 64 { return errors.New("invalid summary entry") } seen := map[string]bool{} @@ -53,7 +58,7 @@ func validateEntry(entry Entry) error { } func validateBlock(block Block) error { - if block.Shown > block.Total || block.Omitted != block.Total-block.Shown || uint64(len(block.Items)) != block.Shown || len(block.Items) > 32 || !validCoverage(block.Coverage) { + if block.Total > maxWireInteger || block.Shown > maxWireInteger || block.Omitted > maxWireInteger || block.Shown > block.Total || block.Omitted != block.Total-block.Shown || uint64(len(block.Items)) != block.Shown || len(block.Items) > 32 || !validCoverage(block.Coverage) { return errors.New("summary block does not reconcile") } for _, entry := range block.Items { @@ -68,7 +73,7 @@ func validateBlock(block Block) error { } func validateErrorBlock(block ErrorBlock) error { - if block.Shown > block.Total || block.Omitted != block.Total-block.Shown || uint64(len(block.Items)) != block.Shown || len(block.Items) > 32 || !validCoverage(block.Coverage) { + if block.Total > maxWireInteger || block.Shown > maxWireInteger || block.Omitted > maxWireInteger || block.Shown > block.Total || block.Omitted != block.Total-block.Shown || uint64(len(block.Items)) != block.Shown || len(block.Items) > 32 || !validCoverage(block.Coverage) { return errors.New("summary error block does not reconcile") } entries := make([]Entry, len(block.Items)) @@ -129,7 +134,7 @@ func ValidateEventPage(page SessionEventPage) error { if err := validateIdentity(page.SchemaVersion, page.MinimumReaderVersion, page.ProjectID, page.Provider, page.SessionID, page.GenerationID, page.SessionViewDigest); err != nil { return err } - if page.RangeStart > page.RangeEnd || page.RangeEnd > page.Total || uint64(len(page.Items)) != page.RangeEnd-page.RangeStart || len(page.Items) > 100 { + if page.Total > maxWireInteger || page.RangeStart > maxWireInteger || page.RangeEnd > maxWireInteger || page.RangeStart > page.RangeEnd || page.RangeEnd > page.Total || uint64(len(page.Items)) != page.RangeEnd-page.RangeStart || len(page.Items) > 100 { return errors.New("event page range does not reconcile") } for _, cursor := range []*string{page.PreviousCursor, page.NextCursor, page.FirstCursor, page.LastCursor} { @@ -147,7 +152,7 @@ func ValidateEventPage(page SessionEventPage) error { return errors.New("event page total does not match indexed coverage") } for index, item := range page.Items { - if !eventKinds[item.Kind] || len(item.Excerpt) > 512 || !validID(item.RevisionID) || item.Sequence == 0 || len(item.OccurredAt) > 128 { + if !eventKinds[item.Kind] || len(item.Excerpt) > 512 || !validID(item.RevisionID) || item.Sequence == 0 || item.Sequence > maxWireInteger || len(item.OccurredAt) > 128 { return fmt.Errorf("invalid event item %d", index) } } diff --git a/internal/inspect/validate_test.go b/internal/inspect/validate_test.go index 9ab8d6e..fb2ea29 100644 --- a/internal/inspect/validate_test.go +++ b/internal/inspect/validate_test.go @@ -30,6 +30,44 @@ func TestValidateRejectsCoverageAdditionOverflow(t *testing.T) { } } +func TestInspectionContractsRejectIntegersAboveJavaScriptSafeMaximum(t *testing.T) { + unsafe := uint64(1 << 53) + + t.Run("summary coverage", func(t *testing.T) { + summary := minimumSummary() + summary.Coverage = Coverage{Seen: unsafe, Indexed: unsafe} + if err := ValidateSummary(summary); err == nil { + t.Fatal("accepted unsafe summary coverage") + } + }) + t.Run("summary sequence", func(t *testing.T) { + summary := minimumSummary() + summary.PhaseBoundaries = Block{ + Total: 1, Shown: 1, Coverage: Coverage{Seen: 1, Indexed: 1}, + Items: []Entry{{OccurredAt: "2026-09-04T00:00:00Z", Sequence: unsafe, RevisionID: "revision-1", SourceRevisionIDs: []string{}}}, + } + if err := ValidateSummary(summary); err == nil { + t.Fatal("accepted unsafe summary sequence") + } + }) + t.Run("event page range and coverage", func(t *testing.T) { + page := minimumEventPage() + page.Total, page.RangeStart, page.RangeEnd = unsafe, unsafe, unsafe + page.Coverage = Coverage{Seen: unsafe, Indexed: unsafe} + if err := ValidateEventPage(page); err == nil { + t.Fatal("accepted unsafe event-page range and coverage") + } + }) + t.Run("event sequence", func(t *testing.T) { + page := minimumEventPage() + page.Total, page.RangeEnd, page.Coverage = 1, 1, Coverage{Seen: 1, Indexed: 1} + page.Items = []EventItem{{Kind: "message", RevisionID: "revision-1", Sequence: unsafe}} + if err := ValidateEventPage(page); err == nil { + t.Fatal("accepted unsafe event sequence") + } + }) +} + func TestParsersRejectFrozenInvalidFixtures(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/memory/api_compat_test.go b/internal/memory/api_compat_test.go index e853009..57fb0d9 100644 --- a/internal/memory/api_compat_test.go +++ b/internal/memory/api_compat_test.go @@ -113,6 +113,50 @@ func TestExpandedV4SchemasEnforceRevisionAndSafeIntegerBoundaries(t *testing.T) } } +func TestV4SchemasBoundEveryPersistedIntegerToJavaScriptSafeMaximum(t *testing.T) { + names := []string{"review-presentation-v4", "machine-ledger-v4", "session-index-v1", "session-summary-v1", "session-event-page-v1", "agent-annotation-v1", "pricing-snapshot-v1", "pricing-supplement-v1", "conversation-chain-v1", "problem-map-candidate-v1"} + for _, name := range names { + t.Run(name, func(t *testing.T) { + schema := readContractJSON(t, filepath.Join("..", "..", "schemas", name+".schema.json")) + if err := validateIntegerSchemaMaximum(schema, "$"); err != nil { + t.Fatal(err) + } + }) + } +} + +func validateIntegerSchemaMaximum(value any, path string) error { + object, ok := value.(map[string]any) + if ok { + integerType := object["type"] == "integer" + if types, typesOK := object["type"].([]any); typesOK { + for _, candidate := range types { + integerType = integerType || candidate == "integer" + } + } + if integerType { + maximum, exists := object["maximum"].(json.Number) + if !exists || numberFloat(maximum) > 9007199254740991 { + return fmt.Errorf("%s: persisted integer is not bounded to the JavaScript safe maximum", path) + } + } + for key, child := range object { + if err := validateIntegerSchemaMaximum(child, path+"."+key); err != nil { + return err + } + } + return nil + } + if array, ok := value.([]any); ok { + for index, child := range array { + if err := validateIntegerSchemaMaximum(child, fmt.Sprintf("%s[%d]", path, index)); err != nil { + return err + } + } + } + return nil +} + func TestV4SchemasAllowHonestUnknownSessionTimesAndLosslessLegacyDecisionStatus(t *testing.T) { indexSchema := readContractJSON(t, filepath.Join("..", "..", "schemas", "session-index-v1.schema.json")) index := readContractJSON(t, filepath.Join("..", "..", "testdata", "contracts", "v4", "session-index-v1.unknown.valid.json")) diff --git a/internal/pricing/validate.go b/internal/pricing/validate.go index 0e4080a..32edee3 100644 --- a/internal/pricing/validate.go +++ b/internal/pricing/validate.go @@ -16,10 +16,11 @@ var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) const ( - maxID = 256 - maxText = 4096 - maxTimestamp = 128 - maxURL = 2048 + maxID = 256 + maxText = 4096 + maxTimestamp = 128 + maxURL = 2048 + maxWireInteger uint64 = 1<<53 - 1 ) func validID(value string) bool { return len(value) <= maxID && idRE.MatchString(value) } @@ -105,6 +106,9 @@ func ValidateSnapshot(snapshot Snapshot) error { names := []string{"input", "cached_input", "cache_write_input", "output", "reasoning_output"} subtotal := 0.0 for i := range rates { + if quantities[i] > maxWireInteger { + return fmt.Errorf("billable quantity %s exceeds wire integer limit", names[i]) + } if quantities[i] > 0 && (rates[i] == nil || costs[i] == nil) && !missing[names[i]] { return fmt.Errorf("unknown billed dimension %s is not reported", names[i]) } diff --git a/internal/pricing/validate_test.go b/internal/pricing/validate_test.go index a4ec888..7598770 100644 --- a/internal/pricing/validate_test.go +++ b/internal/pricing/validate_test.go @@ -26,6 +26,19 @@ func TestValidateSnapshotRejectsIncompleteMarkedComplete(t *testing.T) { } } +func TestValidateSnapshotRejectsQuantityAboveJavaScriptSafeMaximum(t *testing.T) { + snapshot := completeSnapshot() + zero, four := 0.0, 4.0 + snapshot.Rates.CachedInput = &zero + snapshot.LineCostsUSD.CachedInput = &zero + snapshot.KnownSubtotalUSD = four + snapshot.TotalCostUSD = &four + snapshot.BillableQuantities.CachedInput = 1 << 53 + if err := ValidateSnapshot(snapshot); err == nil { + t.Fatal("accepted billable quantity above the JavaScript safe maximum") + } +} + func TestValidateSnapshotCompletenessAndFreePrice(t *testing.T) { zero := 0.0 ones := func() *float64 { v := 1.0; return &v } diff --git a/internal/reviewv4/codec_test.go b/internal/reviewv4/codec_test.go index a87ec20..3010f5f 100644 --- a/internal/reviewv4/codec_test.go +++ b/internal/reviewv4/codec_test.go @@ -77,6 +77,51 @@ func TestValidatePresentationRejectsDecisionCycleAndBrokenGraph(t *testing.T) { } } +func TestV4ReviewAndLedgerRejectIntegersAboveJavaScriptSafeMaximum(t *testing.T) { + unsafe := 1 << 53 + presentationCases := []struct { + name string + mutate func(*Presentation) + }{ + {name: "presentation revision", mutate: func(value *Presentation) { value.Revision = unsafe }}, + {name: "decision revision", mutate: func(value *Presentation) { + value.Decisions = []Decision{minimumDecision("decision-1", []string{})} + value.Decisions[0].Revision = unsafe + }}, + } + for _, tc := range presentationCases { + t.Run(tc.name, func(t *testing.T) { + value := minimumPresentation() + tc.mutate(&value) + if err := ValidatePresentation(value); err == nil { + t.Fatal("accepted integer above the JavaScript safe maximum") + } + }) + } + + ledgerCases := []struct { + name string + mutate func(*MachineLedger) + }{ + {name: "accepted revision", mutate: func(value *MachineLedger) { value.AcceptedRevision = unsafe }}, + {name: "total duration", mutate: func(value *MachineLedger) { value.Accounting.TotalDurationMS = uint64(unsafe) }}, + {name: "total tokens", mutate: func(value *MachineLedger) { value.Accounting.TotalTokens = uint64(unsafe) }}, + {name: "model tokens", mutate: func(value *MachineLedger) { + value.Accounting.TotalTokens = uint64(unsafe) + value.Accounting.Models = []Model{{Model: "model-1", TotalTokens: uint64(unsafe)}} + }}, + } + for _, tc := range ledgerCases { + t.Run(tc.name, func(t *testing.T) { + value := frozenLedger(t) + tc.mutate(&value) + if err := ValidateLedger(value); err == nil { + t.Fatal("accepted integer above the JavaScript safe maximum") + } + }) + } +} + func TestDecodePresentationAcceptsOnlyLosslessLegacyDecisionStatusShape(t *testing.T) { presentation := minimumPresentation() body, err := json.Marshal(presentation) diff --git a/internal/reviewv4/validate.go b/internal/reviewv4/validate.go index e23406b..426a5b9 100644 --- a/internal/reviewv4/validate.go +++ b/internal/reviewv4/validate.go @@ -37,7 +37,7 @@ func optionalTexts(values *[]string, maximumItems, maximumText int) bool { } func ValidatePresentation(p Presentation) error { - if p.SchemaVersion != 4 || p.MinimumReaderVersion != "0.4.0" || p.MinimumWriterVersion != "0.4.0" || !validID(p.ProjectID) || !validID(p.GenerationID) || !digestRE.MatchString(p.ProjectViewDigest) || p.Revision < 0 { + if p.SchemaVersion != 4 || p.MinimumReaderVersion != "0.4.0" || p.MinimumWriterVersion != "0.4.0" || !validID(p.ProjectID) || !validID(p.GenerationID) || !digestRE.MatchString(p.ProjectViewDigest) || p.Revision < 0 || int64(p.Revision) > maxWireInteger { return errors.New("invalid review presentation metadata") } for _, value := range []string{p.CurrentState.Goal, p.CurrentState.Stage, p.CurrentState.Status, p.CurrentState.NextAction, p.CurrentState.LastVerification} { @@ -67,7 +67,7 @@ func ValidatePresentation(p Presentation) error { } decisions := map[string]Decision{} for i, decision := range p.Decisions { - if !validID(decision.ID) || len(decision.OccurredAt) > 128 || !text(decision.Title, 16384) || !text(decision.Rationale, 16384) || !text(decision.Impact, 16384) || !optionalText(decision.LegacyStatusText, 16384) || !text(decision.ReevaluateWhen, 16384) || decision.Revision < 1 || len(decision.Supersedes) > 256 || len(decision.MilestoneIDs) > 256 || len(decision.SessionRefs) > 256 { + if !validID(decision.ID) || len(decision.OccurredAt) > 128 || !text(decision.Title, 16384) || !text(decision.Rationale, 16384) || !text(decision.Impact, 16384) || !optionalText(decision.LegacyStatusText, 16384) || !text(decision.ReevaluateWhen, 16384) || decision.Revision < 1 || int64(decision.Revision) > maxWireInteger || len(decision.Supersedes) > 256 || len(decision.MilestoneIDs) > 256 || len(decision.SessionRefs) > 256 { return fmt.Errorf("invalid decision %d", i) } if _, exists := decisions[decision.ID]; exists { @@ -495,14 +495,14 @@ func decisionCycle(decisions map[string]Decision) bool { } func ValidateLedger(l MachineLedger) error { - if l.SchemaVersion != 4 || l.MinimumReaderVersion != "0.4.0" || l.MinimumWriterVersion != "0.4.0" || !validID(l.ProjectID) || !validID(l.GenerationID) || !digestRE.MatchString(l.ProjectViewDigest) || l.AcceptedRevision < 0 || !shaRE.MatchString(l.ReviewSHA256) || !shaRE.MatchString(l.HistorySHA256) { + if l.SchemaVersion != 4 || l.MinimumReaderVersion != "0.4.0" || l.MinimumWriterVersion != "0.4.0" || !validID(l.ProjectID) || !validID(l.GenerationID) || !digestRE.MatchString(l.ProjectViewDigest) || l.AcceptedRevision < 0 || int64(l.AcceptedRevision) > maxWireInteger || !shaRE.MatchString(l.ReviewSHA256) || !shaRE.MatchString(l.HistorySHA256) { return errors.New("invalid machine ledger metadata") } if len(l.Sessions) > 65536 || len(l.HumanPatches) > 65536 || len(l.OrphanPatches) > 65536 || len(l.GeneratedBaselines) > 65536 || len(l.PricingSnapshots) > 65536 || len(l.CurrentPricingSnapshotIDs) > 65536 || len(l.Accounting.Models) > 256 { return errors.New("machine ledger exceeds array limit") } - if !money(l.Accounting.TotalCostUSD) { - return errors.New("invalid aggregate cost") + if l.Accounting.TotalDurationMS > uint64(maxWireInteger) || l.Accounting.TotalTokens > uint64(maxWireInteger) || !money(l.Accounting.TotalCostUSD) { + return errors.New("invalid aggregate accounting") } pricingByID := map[string]pricing.Snapshot{} for _, snapshot := range l.PricingSnapshots { @@ -522,7 +522,7 @@ func ValidateLedger(l MachineLedger) error { modelCostsComplete := true modelCost := 0.0 for _, model := range l.Accounting.Models { - if !text(model.Model, 16384) || !money(model.TotalCostUSD) || modelNames[model.Model] { + if !text(model.Model, 16384) || model.TotalTokens > uint64(maxWireInteger) || !money(model.TotalCostUSD) || modelNames[model.Model] { return errors.New("invalid or duplicate accounting model") } modelNames[model.Model] = true diff --git a/internal/sessionindex/validate.go b/internal/sessionindex/validate.go index 1a63791..7fd1ad2 100644 --- a/internal/sessionindex/validate.go +++ b/internal/sessionindex/validate.go @@ -16,6 +16,8 @@ import ( var idRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]*$`) var digestRE = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) +const maxWireInteger uint64 = 1<<53 - 1 + var stateReasons = map[string]bool{ "not_discovered": true, "duplicate_candidate": true, "freeze_terminal": true, "malformed_source_records": true, "unsupported_source_records": true, @@ -27,7 +29,35 @@ var stateReasons = map[string]bool{ func validID(value string) bool { return len(value) <= 256 && idRE.MatchString(value) } func validOptional(value *string, maximum int) bool { return value == nil || len(*value) <= maximum } func validDigest(value *string) bool { return value == nil || digestRE.MatchString(*value) } +func validOptionalInteger(value *uint64) bool { return value == nil || *value <= maxWireInteger } +func validCoverage(coverage Coverage) bool { + for _, value := range []uint64{coverage.Seen, coverage.Indexed, coverage.Collapsed, coverage.Unprojected, coverage.Undecodable, coverage.Truncated} { + if value > maxWireInteger { + return false + } + } + return true +} +func validIndexCoverage(coverage IndexCoverage) bool { + for _, value := range []uint64{coverage.Total, coverage.Complete, coverage.Partial, coverage.Error, coverage.Unprocessed, coverage.SourceAvailable, coverage.SourceUnavailable, coverage.StartedAtKnown, coverage.EndedAtKnown, coverage.UsageKnown} { + if value > maxWireInteger { + return false + } + } + return true +} +func validFactCounts(counts FactCounts) bool { + for _, value := range []uint64{counts.FileChange, counts.Command, counts.Verification, counts.Error, counts.Artifact} { + if value > maxWireInteger { + return false + } + } + return true +} func reconcileCoverage(coverage Coverage) bool { + if !validCoverage(coverage) { + return false + } total, ok := checkedSum(coverage.Indexed, coverage.Collapsed, coverage.Unprojected, coverage.Undecodable, coverage.Truncated) return ok && total == coverage.Seen } @@ -53,6 +83,9 @@ func Validate(document Document) error { if len(document.Sessions) > 65536 { return errors.New("too many sessions") } + if !validIndexCoverage(document.Coverage) { + return errors.New("session index coverage exceeds wire integer limit") + } calculated := IndexCoverage{Total: uint64(len(document.Sessions))} keys := map[SessionKey]bool{} for index, entry := range document.Sessions { @@ -84,6 +117,9 @@ func Validate(document Document) error { if !validTimestamp(entry.StartedAt) || !validTimestamp(entry.EndedAt) || !validOptional(entry.SourceTerminalState, 64) { return fmt.Errorf("invalid session timestamps at %d", index) } + if !validOptionalInteger(entry.DurationMS) || entry.WarningCount > maxWireInteger || !validOptionalInteger(entry.RecordCount) || entry.IndexedEventCount > maxWireInteger || !validFactCounts(entry.FactCounts) { + return fmt.Errorf("session %s has an integer above the wire limit", entry.SessionID) + } if entry.StartedAt != nil { calculated.StartedAtKnown++ } diff --git a/internal/sessionindex/validate_test.go b/internal/sessionindex/validate_test.go index 712f9d4..bd3c99c 100644 --- a/internal/sessionindex/validate_test.go +++ b/internal/sessionindex/validate_test.go @@ -28,6 +28,32 @@ func TestValidateRejectsCoverageAdditionOverflow(t *testing.T) { } } +func TestValidateRejectsPersistedIntegersAboveJavaScriptSafeMaximum(t *testing.T) { + unsafe := uint64(1 << 53) + tests := []struct { + name string + mutate func(*Entry) + }{ + {name: "duration", mutate: func(entry *Entry) { entry.DurationMS = &unsafe }}, + {name: "warning count", mutate: func(entry *Entry) { entry.WarningCount = unsafe }}, + {name: "record count", mutate: func(entry *Entry) { entry.RecordCount = &unsafe }}, + {name: "coverage", mutate: func(entry *Entry) { + entry.Coverage = Coverage{Seen: unsafe, Indexed: unsafe} + entry.IndexedEventCount = unsafe + }}, + {name: "fact count", mutate: func(entry *Entry) { entry.FactCounts.FileChange = unsafe }}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + document := oneSessionDocument() + tc.mutate(&document.Sessions[0]) + if err := Validate(document); err == nil { + t.Fatal("accepted integer above the JavaScript safe maximum") + } + }) + } +} + func TestValidateIdentityUsesProviderAndSessionIDPair(t *testing.T) { d := minimumDocument() d.Sessions = []Entry{{Provider: "claude", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: strptr("now"), EndedAt: strptr("now")}, {Provider: "codex", SessionID: "same", ProcessingState: ProcessingComplete, SourceAvailability: "available", StartedAt: strptr("now"), EndedAt: strptr("now")}} diff --git a/obsidian-plugin/src/contracts/review-v4.ts b/obsidian-plugin/src/contracts/review-v4.ts index 82fa73d..bd24f1f 100644 --- a/obsidian-plugin/src/contracts/review-v4.ts +++ b/obsidian-plugin/src/contracts/review-v4.ts @@ -6,6 +6,10 @@ export type ProcessingState = "complete" | "partial" | "error" | "unprocessed"; export type SourceAvailability = "available" | "unavailable"; export type DecisionStatus = "active" | "superseded" | "archived" | "legacy_unmapped"; export type CandidateStatus = "pending" | "confirmed" | "ignored" | "not_decision" | "stale"; +export type ProblemWorkflowStateV4 = "not_started" | "in_progress" | "paused" | "resolved"; +export type ProblemAnswerStateV4 = "no_answer" | "answered_unverified" | "execution_verified"; +export type ProblemProvenanceV4 = "human_created" | "migrated" | "candidate_confirmed"; +export type ConversationVerificationStateV1 = "unknown" | "passed" | "failed" | "partial"; export type PriceStatus = | "pending" | "current" @@ -80,12 +84,12 @@ export interface ProblemNodeV4 { question: string; primary_parent_id: string | null; related_node_ids: string[]; - workflow_state: string; - answer_state: string; + workflow_state: ProblemWorkflowStateV4; + answer_state: ProblemAnswerStateV4; completion_criterion: string; current_conclusion: string; source_turn_refs: SourceTurnRefV4[]; - provenance: string; + provenance: ProblemProvenanceV4; first_proposed_at: string; sibling_order: number; confirmed_at: string | null; @@ -554,7 +558,7 @@ export interface ConversationChainV1 { revision_id: string; source_ref: ConversationSourceRefV1; kind: string; - verification_state: string; + verification_state: ConversationVerificationStateV1; excerpt: string; }>; answer_state: "no_answer" | "answered" | "partial"; diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index 7601763..8f5b1bd 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -1,7 +1,7 @@ import { readFile } from "node:fs/promises"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; +import { describe, expect, expectTypeOf, it } from "vitest"; import { assertProblemGraph, assertSnapshotBindings, @@ -20,7 +20,7 @@ import { WireRejectionError, type WireRejectionCode } from "../src/data/contracts-v4"; -import type { ViewKind } from "../src/contracts/review-v4"; +import type { ConversationChainV1, ProblemNodeV4, ViewKind } from "../src/contracts/review-v4"; const here = dirname(fileURLToPath(import.meta.url)); const pluginFixture = (name: string): Promise => @@ -48,6 +48,15 @@ const contracts: ReadonlyArray { + it("does not widen problem states and result verification states to string", () => { + expectTypeOf().toEqualTypeOf<"not_started" | "in_progress" | "paused" | "resolved">(); + expectTypeOf().toEqualTypeOf<"no_answer" | "answered_unverified" | "execution_verified">(); + expectTypeOf().toEqualTypeOf<"human_created" | "migrated" | "candidate_confirmed">(); + expectTypeOf().toEqualTypeOf<"unknown" | "passed" | "failed" | "partial">(); + }); +}); + function captureRejection(action: () => unknown): WireRejectionError { try { action(); diff --git a/schemas/agent-annotation-v1.schema.json b/schemas/agent-annotation-v1.schema.json index cce8303..91e92a8 100644 --- a/schemas/agent-annotation-v1.schema.json +++ b/schemas/agent-annotation-v1.schema.json @@ -30,7 +30,7 @@ "schema_version": { "const": 1 }, "analysis_profile": { "$ref": "#/$defs/id" }, "agent_run_id": { "$ref": "#/$defs/id" }, "dependencies": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/dependency" } }, - "revision": { "type": "integer", "minimum": 1 }, "created_at": { "$ref": "#/$defs/timestamp" }, + "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "created_at": { "$ref": "#/$defs/timestamp" }, "confirmed_entity_id": { "oneOf": [{ "$ref": "#/$defs/id" }, { "type": "null" }] }, "target_milestone_id": { "$ref": "#/$defs/id" }, "prompt_schema_version": { "$ref": "#/$defs/id" } }, diff --git a/schemas/machine-ledger-v4.schema.json b/schemas/machine-ledger-v4.schema.json index ce76d4e..2b745eb 100644 --- a/schemas/machine-ledger-v4.schema.json +++ b/schemas/machine-ledger-v4.schema.json @@ -6,13 +6,13 @@ "type": "object", "additionalProperties": false, "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "accepted_revision", "review_sha256", "history_sha256", "accounting", "sessions", "human_patches", "orphan_patches", "generated_baselines", "pricing_snapshots", "current_pricing_snapshot_ids", "sync_hashes"], "properties": { - "schema_version": { "const": 4 }, "minimum_reader_version": { "const": "0.4.0" }, "minimum_writer_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "accepted_revision": { "type": "integer", "minimum": 0 }, "review_sha256": { "$ref": "#/$defs/sha256" }, "history_sha256": { "$ref": "#/$defs/sha256" }, + "schema_version": { "const": 4 }, "minimum_reader_version": { "const": "0.4.0" }, "minimum_writer_version": { "const": "0.4.0" }, "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "accepted_revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "review_sha256": { "$ref": "#/$defs/sha256" }, "history_sha256": { "$ref": "#/$defs/sha256" }, "accounting": { "$ref": "#/$defs/accounting" }, "sessions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/session" } }, "human_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "orphan_patches": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/patch" } }, "generated_baselines": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/baseline" } }, "pricing_snapshots": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/snapshot" } }, "current_pricing_snapshot_ids": { "$ref": "#/$defs/id_array" }, "sync_hashes": { "$ref": "#/$defs/sync_hashes" } }, "$defs": { - "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "nonnegative": { "type": "integer", "minimum": 0 }, "id_array": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, + "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "text": { "type": "string", "maxLength": 16384 }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "maxLength": 128 }, "nonnegative": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "id_array": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, "accounting": { "type": "object", "additionalProperties": false, "required": ["total_duration_ms", "total_tokens", "total_cost_usd", "models"], "properties": { "total_duration_ms": { "$ref": "#/$defs/nonnegative" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": ["number", "null"], "minimum": 0 }, "models": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/model" } } } }, "model": { "type": "object", "additionalProperties": false, "required": ["model", "total_tokens", "total_cost_usd"], "properties": { "model": { "$ref": "#/$defs/text" }, "total_tokens": { "$ref": "#/$defs/nonnegative" }, "total_cost_usd": { "type": ["number", "null"], "minimum": 0 } } }, "session": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id", "processing_state", "source_availability", "session_view_digest", "usage_record_digest"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" }, "processing_state": { "enum": ["complete", "partial", "error", "unprocessed"] }, "source_availability": { "enum": ["available", "unavailable"] }, "session_view_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, "usage_record_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" } } }, diff --git a/schemas/pricing-snapshot-v1.schema.json b/schemas/pricing-snapshot-v1.schema.json index 09e4c59..641f6fc 100644 --- a/schemas/pricing-snapshot-v1.schema.json +++ b/schemas/pricing-snapshot-v1.schema.json @@ -46,7 +46,7 @@ "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "$ref": "#/$defs/nullable_money" }, "cached_input": { "$ref": "#/$defs/nullable_money" }, "cache_write_input": { "$ref": "#/$defs/nullable_money" }, "output": { "$ref": "#/$defs/nullable_money" }, "reasoning_output": { "$ref": "#/$defs/nullable_money" } } }, - "nonnegative": { "type": "integer", "minimum": 0 }, + "nonnegative": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "complete_rates": { "type": "object", "additionalProperties": false, "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "type": "number", "minimum": 0 }, "cached_input": { "type": "number", "minimum": 0 }, "cache_write_input": { "type": "number", "minimum": 0 }, "output": { "type": "number", "minimum": 0 }, "reasoning_output": { "type": "number", "minimum": 0 } } }, "complete_line_costs": { "type": "object", "additionalProperties": false, "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "type": "number", "minimum": 0 }, "cached_input": { "type": "number", "minimum": 0 }, "cache_write_input": { "type": "number", "minimum": 0 }, "output": { "type": "number", "minimum": 0 }, "reasoning_output": { "type": "number", "minimum": 0 } } }, "complete_quantities": { "type": "object", "additionalProperties": false, "required": ["input", "cached_input", "cache_write_input", "output", "reasoning_output"], "properties": { "input": { "$ref": "#/$defs/nonnegative" }, "cached_input": { "$ref": "#/$defs/nonnegative" }, "cache_write_input": { "$ref": "#/$defs/nonnegative" }, "output": { "$ref": "#/$defs/nonnegative" }, "reasoning_output": { "$ref": "#/$defs/nonnegative" } } } diff --git a/schemas/review-presentation-v4.schema.json b/schemas/review-presentation-v4.schema.json index f64b0bd..89db24d 100644 --- a/schemas/review-presentation-v4.schema.json +++ b/schemas/review-presentation-v4.schema.json @@ -6,7 +6,7 @@ "required": ["schema_version", "minimum_reader_version", "minimum_writer_version", "project_id", "generation_id", "project_view_digest", "revision", "current_state", "timeline", "decisions", "risks", "open_loops", "problem_map_revision", "problem_root_ids", "problem_nodes", "chain_dependencies", "human_patches", "orphan_patches", "generated_baselines"], "properties": { "schema_version": { "const": 4 }, "minimum_reader_version": { "const": "0.4.0" }, "minimum_writer_version": { "const": "0.4.0" }, - "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "revision": { "type": "integer", "minimum": 0 }, + "project_id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "project_view_digest": { "$ref": "#/$defs/digest" }, "revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "current_state": { "$ref": "#/$defs/current_state" }, "timeline": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/timeline" } }, "decisions": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/decision" } }, "risks": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/risk" } }, "open_loops": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/open_loop" } }, "problem_map_revision": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "problem_root_ids": { "type": "array", "maxItems": 65536, "items": { "$ref": "#/$defs/id" } }, @@ -27,7 +27,7 @@ "closed_loop_coverage": { "type": "object", "additionalProperties": false, "required": ["source_turns", "captured_turns", "truncated_turns", "source_unavailable_turns"], "properties": { "source_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "captured_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "truncated_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "source_unavailable_turns": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 } } }, "closed_loop": { "type": "object", "additionalProperties": false, "required": ["trigger_question", "conclusion", "execution", "verification", "impact_and_follow_up", "source_turn_refs", "coverage"], "properties": { "trigger_question": { "$ref": "#/$defs/closed_loop_segment" }, "conclusion": { "$ref": "#/$defs/closed_loop_conclusion" }, "execution": { "$ref": "#/$defs/closed_loop_segment" }, "verification": { "$ref": "#/$defs/closed_loop_segment" }, "impact_and_follow_up": { "$ref": "#/$defs/closed_loop_segment" }, "source_turn_refs": { "$ref": "#/$defs/source_turn_refs" }, "coverage": { "$ref": "#/$defs/closed_loop_coverage" } } }, "timeline": { "type": "object", "additionalProperties": false, "required": ["id", "generation_id", "occurred_at", "kind", "title", "summary", "decision_ids", "closed_loop"], "properties": { "id": { "$ref": "#/$defs/id" }, "generation_id": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "kind": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "summary": { "$ref": "#/$defs/text" }, "decision_ids": { "$ref": "#/$defs/id_array" }, "closed_loop": { "$ref": "#/$defs/closed_loop" } } }, - "decision": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "occurred_at", "title", "rationale", "impact", "status", "legacy_status_text", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "kind": { "enum": ["decision", "agreement"] }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "title": { "$ref": "#/$defs/text" }, "rationale": { "$ref": "#/$defs/text" }, "impact": { "$ref": "#/$defs/text" }, "status": { "enum": ["active", "superseded", "archived", "legacy_unmapped"] }, "legacy_status_text": { "type": ["string", "null"], "maxLength": 16384 }, "reevaluate_when": { "$ref": "#/$defs/text" }, "supersedes": { "$ref": "#/$defs/id_array" }, "milestone_ids": { "$ref": "#/$defs/id_array" }, "session_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/session_ref" } }, "provenance": { "enum": ["human_created", "migrated", "ai_candidate_confirmed"] }, "pinned": { "type": "boolean" }, "revision": { "type": "integer", "minimum": 1 } }, "allOf": [{ "if": { "properties": { "status": { "const": "legacy_unmapped" } }, "required": ["status"] }, "then": { "properties": { "legacy_status_text": { "type": "string" }, "provenance": { "const": "migrated" } } }, "else": { "properties": { "legacy_status_text": { "type": "null" } } } }] }, + "decision": { "type": "object", "additionalProperties": false, "required": ["id", "kind", "occurred_at", "title", "rationale", "impact", "status", "legacy_status_text", "reevaluate_when", "supersedes", "milestone_ids", "session_refs", "provenance", "pinned", "revision"], "properties": { "id": { "$ref": "#/$defs/id" }, "kind": { "enum": ["decision", "agreement"] }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "title": { "$ref": "#/$defs/text" }, "rationale": { "$ref": "#/$defs/text" }, "impact": { "$ref": "#/$defs/text" }, "status": { "enum": ["active", "superseded", "archived", "legacy_unmapped"] }, "legacy_status_text": { "type": ["string", "null"], "maxLength": 16384 }, "reevaluate_when": { "$ref": "#/$defs/text" }, "supersedes": { "$ref": "#/$defs/id_array" }, "milestone_ids": { "$ref": "#/$defs/id_array" }, "session_refs": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/session_ref" } }, "provenance": { "enum": ["human_created", "migrated", "ai_candidate_confirmed"] }, "pinned": { "type": "boolean" }, "revision": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 } }, "allOf": [{ "if": { "properties": { "status": { "const": "legacy_unmapped" } }, "required": ["status"] }, "then": { "properties": { "legacy_status_text": { "type": "string" }, "provenance": { "const": "migrated" } } }, "else": { "properties": { "legacy_status_text": { "type": "null" } } } }] }, "session_ref": { "type": "object", "additionalProperties": false, "required": ["provider", "session_id"], "properties": { "provider": { "$ref": "#/$defs/id" }, "session_id": { "$ref": "#/$defs/id" } } }, "risk": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "detail"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "detail": { "$ref": "#/$defs/text" } } }, "open_loop": { "type": "object", "additionalProperties": false, "required": ["id", "title", "status", "question", "next_experiment", "completion_criterion"], "properties": { "id": { "$ref": "#/$defs/id" }, "title": { "$ref": "#/$defs/text" }, "status": { "$ref": "#/$defs/text" }, "question": { "$ref": "#/$defs/text" }, "next_experiment": { "$ref": "#/$defs/text" }, "completion_criterion": { "$ref": "#/$defs/text" } } }, diff --git a/schemas/session-event-page-v1.schema.json b/schemas/session-event-page-v1.schema.json index 9c9bdad..2046210 100644 --- a/schemas/session-event-page-v1.schema.json +++ b/schemas/session-event-page-v1.schema.json @@ -17,13 +17,13 @@ "$defs": { "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, - "nonnegative": { "type": "integer", "minimum": 0 }, + "nonnegative": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "cursor": { "type": ["string", "null"], "maxLength": 4096 }, "timestamp": { "type": "string", "maxLength": 128 }, "item": { "type": "object", "additionalProperties": false, "required": ["kind", "excerpt", "revision_id", "sequence", "occurred_at"], - "properties": { "kind": { "enum": ["message", "tool_call", "tool_result", "cwd_change", "usage", "skip", "file_change", "command", "verification", "error", "artifact"] }, "excerpt": { "type": "string", "maxLength": 512 }, "revision_id": { "$ref": "#/$defs/id" }, "sequence": { "type": "integer", "minimum": 1 }, "occurred_at": { "$ref": "#/$defs/timestamp" } } + "properties": { "kind": { "enum": ["message", "tool_call", "tool_result", "cwd_change", "usage", "skip", "file_change", "command", "verification", "error", "artifact"] }, "excerpt": { "type": "string", "maxLength": 512 }, "revision_id": { "$ref": "#/$defs/id" }, "sequence": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "occurred_at": { "$ref": "#/$defs/timestamp" } } }, "coverage": { "type": "object", "additionalProperties": false, diff --git a/schemas/session-index-v1.schema.json b/schemas/session-index-v1.schema.json index c32e444..f38e168 100644 --- a/schemas/session-index-v1.schema.json +++ b/schemas/session-index-v1.schema.json @@ -22,7 +22,7 @@ "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, "timestamp": { "type": "string", "minLength": 1, "maxLength": 128 }, - "nonnegative": { "type": "integer", "minimum": 0 }, + "nonnegative": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "id_array": { "type": "array", "maxItems": 256, "items": { "$ref": "#/$defs/id" } }, "index_coverage": { "type": "object", "additionalProperties": false, @@ -45,8 +45,8 @@ "source_availability": { "enum": ["available", "unavailable"] }, "source_terminal_state": { "type": ["string", "null"], "maxLength": 64 }, "started_at": { "anyOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }] }, "ended_at": { "anyOf": [{ "$ref": "#/$defs/timestamp" }, { "type": "null" }] }, - "duration_ms": { "type": ["integer", "null"], "minimum": 0 }, "warning_count": { "$ref": "#/$defs/nonnegative" }, - "record_count": { "type": ["integer", "null"], "minimum": 0 }, "indexed_event_count": { "$ref": "#/$defs/nonnegative" }, + "duration_ms": { "type": ["integer", "null"], "minimum": 0, "maximum": 9007199254740991 }, "warning_count": { "$ref": "#/$defs/nonnegative" }, + "record_count": { "type": ["integer", "null"], "minimum": 0, "maximum": 9007199254740991 }, "indexed_event_count": { "$ref": "#/$defs/nonnegative" }, "coverage": { "$ref": "#/$defs/session_coverage" }, "fact_counts": { "$ref": "#/$defs/fact_counts" }, "session_view_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, "usage_record_digest": { "type": ["string", "null"], "pattern": "^sha256:[0-9a-f]{64}$" }, diff --git a/schemas/session-summary-v1.schema.json b/schemas/session-summary-v1.schema.json index 269c341..a620eb4 100644 --- a/schemas/session-summary-v1.schema.json +++ b/schemas/session-summary-v1.schema.json @@ -13,16 +13,16 @@ "$defs": { "id": { "type": "string", "minLength": 1, "maxLength": 256, "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]*$" }, "digest": { "type": "string", "pattern": "^sha256:[0-9a-f]{64}$" }, - "text": { "type": "string", "maxLength": 512 }, "nonnegative": { "type": "integer", "minimum": 0 }, + "text": { "type": "string", "maxLength": 512 }, "nonnegative": { "type": "integer", "minimum": 0, "maximum": 9007199254740991 }, "timestamp": { "type": "string", "maxLength": 128 }, "block": { "type": "object", "additionalProperties": false, "required": ["total", "shown", "omitted", "coverage", "items"], "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" }, "coverage": { "$ref": "#/$defs/coverage" }, "items": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/entry" } } } }, "error_block": { "type": "object", "additionalProperties": false, "required": ["total", "shown", "omitted", "coverage", "items"], "properties": { "total": { "$ref": "#/$defs/nonnegative" }, "shown": { "$ref": "#/$defs/nonnegative" }, "omitted": { "$ref": "#/$defs/nonnegative" }, "coverage": { "$ref": "#/$defs/coverage" }, "items": { "type": "array", "maxItems": 32, "items": { "$ref": "#/$defs/error_entry" } } } }, "entry": { "type": "object", "additionalProperties": false, "required": ["occurred_at", "sequence", "revision_id", "text", "source_revision_ids"], - "properties": { "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } + "properties": { "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } }, - "error_entry": { "type": "object", "additionalProperties": false, "required": ["code", "occurred_at", "sequence", "revision_id", "text", "source_revision_ids"], "properties": { "code": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } }, + "error_entry": { "type": "object", "additionalProperties": false, "required": ["code", "occurred_at", "sequence", "revision_id", "text", "source_revision_ids"], "properties": { "code": { "$ref": "#/$defs/id" }, "occurred_at": { "$ref": "#/$defs/timestamp" }, "sequence": { "type": "integer", "minimum": 1, "maximum": 9007199254740991 }, "revision_id": { "$ref": "#/$defs/id" }, "text": { "$ref": "#/$defs/text" }, "source_revision_ids": { "type": "array", "maxItems": 64, "items": { "$ref": "#/$defs/id" } } } }, "rules": { "type": "object", "additionalProperties": false, "required": ["rule_id", "rule_version", "dependency_digests"], From c535f97a0e42d04a008df8bec314d49a84ebc112 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 10:24:03 +0800 Subject: [PATCH 23/25] fix: preserve integer lexemes at TS wire boundary --- obsidian-plugin/src/data/contracts-v4.ts | 70 ++++++++++++++++++++-- obsidian-plugin/tests/contracts-v4.test.ts | 12 ++++ 2 files changed, 78 insertions(+), 4 deletions(-) diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index 1fe1411..1cbf3de 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -53,6 +53,19 @@ const ZERO_SHA256 = "0".repeat(64); const SORT_VERSION = "started-at-desc-null-last-provider-session-v1"; const COVERAGE_KEYS = ["seen", "indexed", "collapsed", "unprojected", "undecodable", "truncated"] as const; const PRICE_DIMENSIONS = ["input", "cached_input", "cache_write_input", "output", "reasoning_output"] as const; +const UNAMBIGUOUS_INTEGER_KEYS = new Set([ + "schema_version", "revision", "accepted_revision", "problem_map_revision", "sibling_order", + "source_turns", "captured_turns", "truncated_turns", "source_unavailable_turns", + "total_duration_ms", "total_tokens", "duration_ms", "warning_count", "record_count", + "indexed_event_count", "total", "shown", "omitted", "sequence", "range_start", "range_end", + "record_ordinal", "ordinal", "source_messages", "captured_messages", "turn_units", + "unanswered_units", "truncated_messages" +]); +const COVERAGE_INTEGER_KEYS = new Set([ + ...COVERAGE_KEYS, "complete", "partial", "error", "unprocessed", "source_available", + "source_unavailable", "started_at_known", "ended_at_known", "usage_known" +]); +const FACT_COUNT_INTEGER_KEYS = new Set(["file_change", "command", "verification", "error", "artifact"]); type JsonObject = Record; @@ -1986,7 +1999,7 @@ function rejectDuplicateJsonKeys(source: string): void { } throw new Error("decode JSON: unterminated string"); }; - const parseValue = (): void => { + const parseValue = (path: Array): void => { whitespace(); const token = source[cursor]; if (token === "{") { @@ -2006,7 +2019,7 @@ function rejectDuplicateJsonKeys(source: string): void { whitespace(); if (source[cursor] !== ":") throw new Error("decode JSON: missing object colon"); cursor += 1; - parseValue(); + parseValue([...path, key]); whitespace(); if (source[cursor] === "}") { cursor += 1; @@ -2024,8 +2037,10 @@ function rejectDuplicateJsonKeys(source: string): void { cursor += 1; return; } + let index = 0; while (cursor < source.length) { - parseValue(); + parseValue([...path, index]); + index += 1; whitespace(); if (source[cursor] === "]") { cursor += 1; @@ -2042,13 +2057,60 @@ function rejectDuplicateJsonKeys(source: string): void { } const primitive = /^(?:-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?|true|false|null)/.exec(source.slice(cursor)); if (!primitive) throw new Error("decode JSON: malformed value"); + if (isWireIntegerPath(path) && /^-?\d/.test(primitive[0])) { + assertExactSafeIntegerLexeme(primitive[0], path); + } cursor += primitive[0].length; }; - parseValue(); + parseValue([]); whitespace(); if (cursor !== source.length) throw new Error("decode JSON: trailing data"); } +function isWireIntegerPath(path: ReadonlyArray): boolean { + const key = path[path.length - 1]; + if (typeof key !== "string") return false; + if (UNAMBIGUOUS_INTEGER_KEYS.has(key)) return true; + const parent = path[path.length - 2]; + if (parent === "coverage" && COVERAGE_INTEGER_KEYS.has(key)) return true; + if (parent === "fact_counts" && FACT_COUNT_INTEGER_KEYS.has(key)) return true; + return parent === "billable_quantities" && PRICE_DIMENSIONS.includes(key as typeof PRICE_DIMENSIONS[number]); +} + +function assertExactSafeIntegerLexeme(source: string, path: ReadonlyArray): void { + const match = /^(-?)(\d+)(?:\.(\d+))?(?:[eE]([+-]?\d+))?$/.exec(source); + if (!match) reject("wire_shape_invalid", `${jsonPath(path)} must be an exact safe integer`); + const negative = match[1] === "-"; + const fraction = match[3] ?? ""; + const exponent = Number(match[4] ?? "0"); + let digits = `${match[2]}${fraction}`.replace(/^0+/, ""); + if (digits === "") return; + if (!Number.isSafeInteger(exponent) || Math.abs(exponent) > 1024) { + reject("wire_shape_invalid", `${jsonPath(path)} must be an exact safe integer`); + } + const scale = fraction.length - exponent; + if (scale > 0) { + if (scale >= digits.length || !/^0+$/.test(digits.slice(-scale))) { + reject("wire_shape_invalid", `${jsonPath(path)} must be an exact safe integer`); + } + digits = digits.slice(0, -scale); + } else if (scale < 0) { + const zeros = -scale; + if (digits.length + zeros > 16) { + reject("wire_shape_invalid", `${jsonPath(path)} must be a safe integer`); + } + digits += "0".repeat(zeros); + } + const exact = BigInt(`${negative ? "-" : ""}${digits}`); + if (exact > BigInt(MAX_SAFE) || exact < BigInt(-MAX_SAFE) || Number(source) !== Number(exact)) { + reject("wire_shape_invalid", `${jsonPath(path)} must be a safe integer`); + } +} + +function jsonPath(path: ReadonlyArray): string { + return `$${path.map((part) => typeof part === "number" ? `[${part}]` : `.${part}`).join("")}`; +} + function message(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index 8f5b1bd..7c15f48 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -247,6 +247,18 @@ describe("strict JSON boundary", () => { expect(() => parseReviewPresentationV4(JSON.stringify(valid))).toThrow(/safe integer/i); }); + it("rejects raw integer literals that JSON.parse would round into valid integers", async () => { + const review = await pluginFixture("review-presentation-v4.valid.json"); + const roundedRevision = review.replace('"revision": 1', '"revision": 9007199254740991.1'); + expect(roundedRevision).not.toBe(review); + expect(() => parseReviewPresentationV4(roundedRevision)).toThrow(/exact|safe integer/i); + + const chain = await pluginFixture("conversation-chain-v1.valid.json"); + const roundedOrdinal = chain.replace('"ordinal": 1', '"ordinal": 1.0000000000000001'); + expect(roundedOrdinal).not.toBe(chain); + expect(() => parseConversationChainV1(roundedOrdinal)).toThrow(/exact|safe integer/i); + }); + it("applies string ceilings to UTF-8 bytes for CJK and emoji", async () => { const review = await fixtureObject("review-presentation-v4.valid.json") as { current_state: JsonObject }; review.current_state.goal = `${"界".repeat(5461)}a`; From f676dbfc02ac0b359415af5d2bb9daee71416c2b Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 10:30:37 +0800 Subject: [PATCH 24/25] fix: preserve integer wire lexemes --- obsidian-plugin/src/data/contracts-v4.ts | 16 ++++++++++------ obsidian-plugin/tests/contracts-v4.test.ts | 8 +++++--- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index 1cbf3de..b85861e 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -2082,11 +2082,15 @@ function assertExactSafeIntegerLexeme(source: string, path: ReadonlyArray 1024) { - reject("wire_shape_invalid", `${jsonPath(path)} must be an exact safe integer`); + if (exponentSource.startsWith("-")) { + reject("wire_shape_invalid", `${jsonPath(path)} must be an exact integer`); + } + return; } const scale = fraction.length - exponent; if (scale > 0) { @@ -2097,14 +2101,14 @@ function assertExactSafeIntegerLexeme(source: string, path: ReadonlyArray 16) { - reject("wire_shape_invalid", `${jsonPath(path)} must be a safe integer`); + return; } digits += "0".repeat(zeros); } + if (digits.length > 16) return; const exact = BigInt(`${negative ? "-" : ""}${digits}`); - if (exact > BigInt(MAX_SAFE) || exact < BigInt(-MAX_SAFE) || Number(source) !== Number(exact)) { - reject("wire_shape_invalid", `${jsonPath(path)} must be a safe integer`); - } + if (exact > BigInt(MAX_SAFE) || exact < BigInt(-MAX_SAFE)) return; + if (Number(source) !== Number(exact)) reject("wire_shape_invalid", `${jsonPath(path)} must be an exact integer`); } function jsonPath(path: ReadonlyArray): string { diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index 7c15f48..339f111 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -244,19 +244,21 @@ describe("strict JSON boundary", () => { it("rejects unsafe integers", async () => { const valid = await fixtureObject("review-presentation-v4.valid.json"); valid.revision = Number.MAX_SAFE_INTEGER + 1; - expect(() => parseReviewPresentationV4(JSON.stringify(valid))).toThrow(/safe integer/i); + const error = captureRejection(() => parseReviewPresentationV4(JSON.stringify(valid))); + expect(error.message).toMatch(/safe integer/i); + expect(codeOf(error)).toBe("wire_contract_invalid"); }); it("rejects raw integer literals that JSON.parse would round into valid integers", async () => { const review = await pluginFixture("review-presentation-v4.valid.json"); const roundedRevision = review.replace('"revision": 1', '"revision": 9007199254740991.1'); expect(roundedRevision).not.toBe(review); - expect(() => parseReviewPresentationV4(roundedRevision)).toThrow(/exact|safe integer/i); + expect(codeOf(captureRejection(() => parseReviewPresentationV4(roundedRevision)))).toBe("wire_shape_invalid"); const chain = await pluginFixture("conversation-chain-v1.valid.json"); const roundedOrdinal = chain.replace('"ordinal": 1', '"ordinal": 1.0000000000000001'); expect(roundedOrdinal).not.toBe(chain); - expect(() => parseConversationChainV1(roundedOrdinal)).toThrow(/exact|safe integer/i); + expect(codeOf(captureRejection(() => parseConversationChainV1(roundedOrdinal)))).toBe("wire_shape_invalid"); }); it("applies string ceilings to UTF-8 bytes for CJK and emoji", async () => { From 03553064da2d4a0729d3eabf3e9b09291bb99f30 Mon Sep 17 00:00:00 2001 From: NeoMei Date: Sat, 5 Sep 2026 10:33:34 +0800 Subject: [PATCH 25/25] fix: validate large integer exponents exactly --- obsidian-plugin/src/data/contracts-v4.ts | 9 ++++++--- obsidian-plugin/tests/contracts-v4.test.ts | 4 ++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/obsidian-plugin/src/data/contracts-v4.ts b/obsidian-plugin/src/data/contracts-v4.ts index b85861e..2e995ea 100644 --- a/obsidian-plugin/src/data/contracts-v4.ts +++ b/obsidian-plugin/src/data/contracts-v4.ts @@ -2083,15 +2083,18 @@ function assertExactSafeIntegerLexeme(source: string, path: ReadonlyArray 1024) { - if (exponentSource.startsWith("-")) { + if (exponentDigits.length > String(MAX_JSON_BYTES).length) { + if (exponentNegative) { reject("wire_shape_invalid", `${jsonPath(path)} must be an exact integer`); } return; } + const exponentMagnitude = Number(exponentDigits); + const exponent = exponentNegative ? -exponentMagnitude : exponentMagnitude; const scale = fraction.length - exponent; if (scale > 0) { if (scale >= digits.length || !/^0+$/.test(digits.slice(-scale))) { diff --git a/obsidian-plugin/tests/contracts-v4.test.ts b/obsidian-plugin/tests/contracts-v4.test.ts index 339f111..b263ae7 100644 --- a/obsidian-plugin/tests/contracts-v4.test.ts +++ b/obsidian-plugin/tests/contracts-v4.test.ts @@ -259,6 +259,10 @@ describe("strict JSON boundary", () => { const roundedOrdinal = chain.replace('"ordinal": 1', '"ordinal": 1.0000000000000001'); expect(roundedOrdinal).not.toBe(chain); expect(codeOf(captureRejection(() => parseConversationChainV1(roundedOrdinal)))).toBe("wire_shape_invalid"); + + const underflowRevision = review.replace('"revision": 1', `"revision": 0.${"0".repeat(2000)}1e1025`); + expect(underflowRevision).not.toBe(review); + expect(codeOf(captureRejection(() => parseReviewPresentationV4(underflowRevision)))).toBe("wire_shape_invalid"); }); it("applies string ceilings to UTF-8 bytes for CJK and emoji", async () => {