diff --git a/.qa/memory/findings.jsonl b/.qa/memory/findings.jsonl index 1ac9902..8f60f1d 100644 --- a/.qa/memory/findings.jsonl +++ b/.qa/memory/findings.jsonl @@ -58,3 +58,32 @@ {"id":"NS-UI-COMPOSER-ROW","sev":"P1","area":"agent-composer","symptom":"composer-sat-above-a-phantom-blank-grid-row","rootCause":"assistant-thread-auto-landed-in-grid-row-one","evidence":"artifacts/nodeslide-ai-rail-fix-2026-07-15/compact-desktop-composer-anchored.png","fix":"pin-thread-to-grid-row-two-and-preserve-sticky-footer","status":"fixed","ts":"2026-07-16T06:30:00.000Z","fp":"0c5f6a9f3a1c"} {"id":"NS-UI-SELECTION-SCOPE","sev":"P1","area":"agent-authority","symptom":"selected-element-did-not-update-visible-write-scope","rootCause":"scope-state-ignored-selection-transitions","evidence":"AiInspector.activity.test.tsx-selection-scope-transition","fix":"auto-narrow-unless-user-explicitly-overrode-scope","status":"fixed","ts":"2026-07-16T06:30:00.000Z","fp":"146210e94bee"} {"id":"NS-UI-RAW-ERROR","sev":"P1","area":"failure-presentation","symptom":"provider-failure-exposed-raw-Convex-stack","rootCause":"wrapped-errors-bypassed-public-message-extraction","evidence":"artifacts/nodeslide-ai-rail-fix-2026-07-15/compact-desktop-stable.png","fix":"extract-and-bound-public-message-while-retaining-telemetry-detail","status":"fixed","ts":"2026-07-16T06:30:00.000Z","fp":"4e98ff747f4b"} +{"sev":"P1","area":"long-conversation-transcript","symptom":"completed-multi-step-tool-activity-flooded-transcript-and-obscured-final-assistant-answer","rootCause":"nested-run-steps-and-terminal-answer-had-equal-visual-priority","evidence":"NodeSlideAgentThread-6-of-6-plus-mobile-and-desktop-browser-QA","fix":"promote-final-answer-and-collapse-completed-activity-with-progressive-disclosure","status":"fixed","ts":"2026-07-16T09:24:45.020Z","fp":"54431db6182c"} +{"sev":"P1","area":"mobile-agent-composer","symptom":"composer-controls-consumed-most-of-viewport-and-displaced-long-run-status-and-conversation","rootCause":"advanced-controls-and-visual-workbench-were-permanently-expanded","evidence":"390x844-browser-QA-compact-composer-251px-zero-horizontal-overflow","fix":"move-visual-workbench-behind-Tools-and-advanced-controls-behind-Expand","status":"fixed","ts":"2026-07-16T09:24:45.423Z","fp":"7231e2fad2fc"} +{"sev":"P1","area":"parallel-agent-activity","symptom":"planner-and-executor-steps-lacked-persisted-branch-identity-and-could-not-honestly-summarize-parallel-work","rootCause":"message-schema-had-no-role-branch-or-parallel-group-metadata","evidence":"F17-F18-persisted-metadata-regression-in-NodeSlideAgentThread-tests","fix":"persist-optional-role-branch-and-parallel-group-metadata-and-aggregate-only-factual-groups","status":"fixed","ts":"2026-07-16T09:24:45.792Z","fp":"7bb857590a69"} +{"symptom":"U08 mobile agent drawer blanked the entire editor","fix":"render immutable nested ThreadMessage arrays recursively while retaining top-level assistant-ui runtime and Tool UI","area":"mobile-long-conversation","evidence":"390x844 browser reproduction plus useClientLookup Index 1 out of bounds","rootCause":"nested assistant-ui Messages lookup used positional client state that became out of bounds after long multi-step activity","status":"fixed","sev":"P0","ts":"2026-07-16T10:16:07.336Z","fp":"67e6537b58fd"} +{"symptom":"failed evidence capture card text was unreadable in dark mode","fix":"use theme tokens and explicit retry/focus states","area":"evidence-failure-presentation","evidence":"dark-theme browser visual check","rootCause":"hard-coded failed-state colors had insufficient contrast","status":"fixed","sev":"P1","ts":"2026-07-16T10:16:07.612Z","fp":"138416b3bfe4"} +{"symptom":"trace inspector selected deterministic brief-to-deck route while displaying spans from the failed live S01 research run","fix":"open: require exact run id and span ancestry consistency before rendering timeline","area":"trace-integrity","evidence":"expanded trace browser screenshot during exact S01","rootCause":"run-to-trace selection or attribution mismatch","status":"open","sev":"P0","ts":"2026-07-16T10:16:07.815Z","fp":"f912c1b36512"} +{"symptom":"switching to AI after selecting a body element cleared selection and widened authority to Writes to this slide","fix":"open: preserve or explicitly disclose selection-bound scope","area":"agent-authority","evidence":"live browser selection then AI drawer transition","rootCause":"selection-to-scope transition regressed","status":"regressed","sev":"P1","ts":"2026-07-16T10:16:08.108Z","fp":"48bd00718fcb"} +{"symptom":"exact S01 web research failed after three bounded attempts before evidence capture","fix":"open: repair provider path and rerun fixed corpus to prove live capture","area":"live-web-research","evidence":"live S01 run with session consent and web enabled","rootCause":"configured web search provider failed at runtime","status":"open","sev":"P1","ts":"2026-07-16T10:16:08.370Z","fp":"5a480372043b"} +{"area":"durable-create-session","sev":"P0","status":"fixed","symptom":"First deterministic create run failed queued to succeeded because workflow pre-marked the job running before runner claim","rootCause":"nodeslideJobWorkflow initial planning checkpoint bypassed durable session claim","fix":"Keep create and edit workflows queued until execute runner claims; add two workflow-order regression tests","evidence":"12 focused tests passed and tsc -b passed; browser creation retry then reached a separate Called by client failure","ts":"2026-07-16T18:05:02.930Z","fp":"a4377d8314de"} +{"area":"deterministic-bounded-edit","sev":"P0","status":"open","symptom":"A requested five-field slide rewrite produced a one-operation move Section label candidate that passed Candidate validation and Deck CI","rootCause":"bounded-edit planner did not preserve semantic intent while validation checked only structural validity","fix":"Require semantic coverage receipt for requested fields before enabling Accept","evidence":"Live NodeSlide Compare showed unchanged baseline and candidate content and only one move operation; proposal was rejected","ts":"2026-07-16T18:05:24.022Z","fp":"16b13bb854ec"} +{"area":"native-text-edit","sev":"P1","status":"open","symptom":"Editing slide 6 headline in Design inspector and blurring did not persist; selection reset to slide 1","rootCause":"client patch write failed or stale selection fallback masked the failed mutation","fix":"Surface durable mutation failure and retain current slide on rejected or failed direct edit","evidence":"Live local NodeSlide connected to configured Convex deployment; deck remained v2 and slide 6 unchanged","ts":"2026-07-16T18:05:24.102Z","fp":"08300cf0a7b1"} +{"symptom":"trace inspector selected deterministic brief-to-deck route while displaying spans from the failed live S01 research run","fix":"fixed: require exact traceId-to-run equality; unmatched runs cannot decorate the selected trace","area":"trace-integrity","evidence":"TraceInspector exact-attribution regression tests; full Vitest 1192/1192","rootCause":"run-to-trace selection or attribution mismatch","status":"fixed","sev":"P0","ts":"2026-07-16T18:14:35.138Z","fp":"f912c1b36512"} +{"symptom":"switching to AI after selecting a body element cleared selection and widened authority to Writes to this slide","fix":"fixed: retain element-bound scope and fail closed until the element is reselected or authority is explicitly widened","area":"agent-authority","evidence":"AiInspector activity selection-loss regression; full Vitest 1192/1192","rootCause":"selection-to-scope transition regressed","status":"fixed","sev":"P1","ts":"2026-07-16T18:14:35.207Z","fp":"48bd00718fcb"} +{"symptom":"exact S01 web research failed after three bounded attempts before evidence capture","fix":"fixed: bounded Linkup searchResults adapter, deterministic source-bound review fallback, and stored source-snapshot PDF fallback","area":"live-web-research","evidence":"production S01 awaiting_review with 1 operation, 6 bound sources, 3 ready captures, and signed PDF detail","rootCause":"configured web search provider failed at runtime","status":"fixed","sev":"P1","ts":"2026-07-16T18:14:35.276Z","fp":"5a480372043b"} +{"symptom":"desktop AI composer truncates the reasoning-effort label inside the narrow inspector rail","fix":"proposed: make the compact control row allocate a stable readable effort label or collapse it behind one named control","area":"agent-composer-responsive-polish","evidence":"artifacts/browser-journey-2026-07-16/04-production-agent-chat-desktop.png","rootCause":"fixed-width model and effort controls compete inside the bounded inspector width","status":"open","sev":"P2","ts":"2026-07-16T18:25:29.490Z","fp":"38a693ccf3e3"} +{"symptom":"mobile AI drawer leaves a large dead transcript gap above the composer and clips the validation status at the viewport edge","fix":"proposed: use content-sized empty state spacing and reserve safe-area room for the validation footer","area":"mobile-agent-chat-polish","evidence":"artifacts/browser-journey-2026-07-16/05-production-agent-chat-mobile.png","rootCause":"full-height drawer flex distribution and footer stacking do not adapt to an empty conversation","status":"open","sev":"P2","ts":"2026-07-16T18:25:29.595Z","fp":"7f8184fcfe0c"} +{"rootCause":"bounded-edit planner did not preserve semantic intent while validation checked only structural validity","sev":"P0","symptom":"A requested five-field slide rewrite produced a one-operation move Section label candidate that passed Candidate validation and Deck CI","status":"fixed","fp":"16b13bb854ec","area":"deterministic-bounded-edit","evidence":"convex/lib/nodeslideEditPlanner.test.ts, convex/lib/nodeslideCandidate.test.ts, convex/lib/nodeslideDeckCi.test.ts; 36 focused tests and 34 delegated-Turbo tests passed; pnpm typecheck passed","fix":"Added a digest-bound semantic coverage receipt for enumerated fields, roles, operation classes, exact replacement text, and slide targets; planner fails closed and Deck CI blocks under-covered or mismatched receipts.","ts":"2026-07-16T18:36:45.439Z"} +{"rootCause":"bounded-edit planner did not preserve semantic intent while validation checked only structural validity","sev":"P0","symptom":"A requested five-field slide rewrite produced a one-operation move Section label candidate that passed Candidate validation and Deck CI","status":"fixed","fp":"16b13bb854ec","area":"deterministic-bounded-edit","evidence":"pnpm test: 154 files and 1207 tests passed; shadow plus semantic focus: 46 tests passed; pnpm typecheck, Biome, and diff check passed","fix":"Added a digest-bound semantic coverage receipt for enumerated fields, roles, operation classes, exact replacement text, and slide targets; planner fails closed and Deck CI blocks under-covered or mismatched receipts.","ts":"2026-07-16T18:40:30.404Z"} +{"symptom":"Editing slide 6 headline in Design inspector and blurring did not persist; selection reset to slide 1","fix":"Route native canvas and Design-inspector blur edits through the canonical versioned CAS mutation, preserve exact slide and selection across authoritative receipts, and surface stale or failed mutations.","sev":"P1","evidence":"NodeSlideStudio.projectActions.test.tsx and DesignInspector.test.tsx: 15/15 passed; pnpm typecheck and Biome passed; integrated E2E matrix 19/19 passed.","rootCause":"client patch write failed or stale selection fallback masked the failed mutation","status":"fixed","area":"native-text-edit","ts":"2026-07-16T18:48:34.803Z","fp":"08300cf0a7b1"} +{"symptom":"no owner-scoped export-my-data bundle exists for a deck","fix":"Added deterministic owner-scoped export v2 with validated relationships, explicit omissions, immutable source revisions and claim receipts, and no secrets or raw storage locators.","sev":"P2","evidence":"convex/lib/nodeslideDataExport.test.ts and ExportMyDataAction.test.tsx passed in the 1207-test full suite; typecheck, lint, build, and 19 E2E journeys passed.","rootCause":"owner data was split across deck, session, memory, trace, upload, sync, and evidence tables without one bounded export contract","status":"fixed","area":"data-rights-export","ts":"2026-07-16T18:48:56.412Z","fp":"c29c6c5c8d42"} +{"id":"NS-C01-SEMANTIC-FALLBACK","fix":"Preserve bounded Topic, Audience, and Desired outcome metadata and carry exact enumerated jobs plus brief context and an evidence-only guard into every deterministic fallback slide.","evidence":"convex/lib/nodeslideSeed.test.ts: 21/21 focused tests pass, including named HelioForge/Maya Chen/Northstar Capital and vague-prompt regressions; exact-root tsc exit 0; scoped Biome exit 0; git diff --check exit 0","rootCause":"the exact job labels were mapped, but deterministic slide bodies replaced named brief context with generic intended-audience wording","sev":"P1","symptom":"fixed C01 fallback creates the requested slide count but replaces the named investor narrative with generic slide jobs","area":"deck-creation-reliability","status":"fixed","fp":"0c8a7da4b785","ts":"2026-07-16T18:57:13.874Z"} +{"symptom":"model picker was a dense raw command menu and opened behind the compact AI drawer on mobile","fix":"Rebuilt the route picker with a visible session receipt, readable route rows, current-state labeling, bounded scrolling, and a dedicated responsive sheet; raised the opt-in dialog overlay above the mobile drawer and removed conflicting mobile transforms.","area":"model-picker-responsive-ux","evidence":"artifacts/model-picker-revamp-2026-07-16/after-model-picker-desktop.png; artifacts/model-picker-revamp-2026-07-16/after-model-picker-mobile.png","rootCause":"The generic command-list layout had no NodeSlide information hierarchy, while its z-50 overlay sat below the mobile inspector z-80 and Tailwind translate combined with a second transform.","status":"fixed","sev":"P1","ts":"2026-07-16T19:00:54.039Z","fp":"759592afdeef"} +{"fp":"38a693ccf3e3","sev":"P2","area":"agent-composer-responsive-polish","symptom":"desktop AI composer truncates the reasoning-effort label inside the narrow inspector rail","rootCause":"the native select reserved browser arrow space inside a fixed 78px control and exposed only a generic accessible name","fix":"Use a compact custom-arrow select treatment and include the selected provider-native effort in its accessible name and title without widening the control.","evidence":"artifacts/nodeslide-polish-2026-07-16/desktop-inspector-effort.png; measured 78px control, Medium selected, clientWidth=scrollWidth=76, zero page overflow; NodeSlidePromptComposer.test.tsx","status":"fixed","ts":"2026-07-16T19:06:28.064Z"} +{"fp":"7f8184fcfe0c","sev":"P2","area":"mobile-agent-chat-polish","symptom":"mobile AI drawer leaves a large dead transcript gap above the composer and clips the validation status at the viewport edge","rootCause":"short threads inherited the long-history bottom anchor and the mobile drawer retained a fixed 32px validation footer row","fix":"Mark empty and short threads content-sized, retain anchored layout for long histories, and give the mobile validation footer an auto-sized safe-area-aware row.","evidence":"artifacts/nodeslide-polish-2026-07-16/mobile-short-thread.png; mobile thread layout=content, content bottom=644, footer 765-820 within 820px viewport, zero horizontal overflow; AiInspector.activity.test.tsx","status":"fixed","ts":"2026-07-16T19:06:28.152Z"} +{"fp":"7e76440c659b","sev":"P2","area":"error-copy","symptom":"raw Convex Server Error string leaks to users on the failure card","rootCause":"the user-error sanitizer treated the literal transport wrapper as acceptable first-line product copy","fix":"Reject transport-only server strings, retain bounded public Convex messages, and expose Open Trace separately without raw or secret details.","evidence":"nodeslideUserError.test.ts and AiInspector.activity.test.tsx; focused suite 43/43 passed","status":"fixed","ts":"2026-07-16T19:06:28.243Z"} +{"sev":"P0","area":"visual-evidence-region-integrity","symptom":"Source-level screenshot or PDF geometry could be promoted as claim-level precision, and PDF boxes could not render on a controlled page surface.","rootCause":"Evidence steps lacked an explicit region scope and the UI used the opaque native PDF object surface.","evidence":"73 focused tests pass including production capture persistence, fail-closed claim receipt scope, selected-detail overlay, PDF page/resize/scroll, invalid geometry, and source-level copy; typecheck and production build pass.","fix":"Persist source or claim regionScope, require claim scope for receipts, derive raster dimensions from exact bytes, render selected PDFs with lazy same-origin PDF.js worker, and label source-level or whole-frame regions explicitly.","status":"fixed","ts":"2026-07-16T19:12:45.834Z","fp":"daf668297150"} +{"fp":"de3f2ddaf973","sev":"P2","area":"agentic-depth-baseline","symptom":"D-tier baseline remains incomplete across iterative runtime, eval, routing, governance, and retention","rootCause":"The live edit route previously stopped at planning, advanced routing lacked an explicit per-run spend ceiling, binary attachment consumption and full-library governance remain unconnected.","evidence":"Live Deck REPL tests and durable executor activity; advanced-control desktop/dark/mobile pixel artifacts; 1265-test repository pass.","fix":"Added bounded live Deck REPL inspect/measure/validate, durable executor activity, explicit hard USD ceiling, decluttered responsive Advanced controls, linked-comment compatibility, and seed collision repair. Keep open for live render-repair, binary ingestion/model-read, automatic routing availability/consent, and full deck library governance.","status":"open","ts":"2026-07-16T19:45:55.139Z"} +{"fp":"de3f2ddaf973","sev":"P2","area":"agentic-depth-baseline","symptom":"D-tier baseline remains incomplete across iterative runtime, eval, routing, governance, and retention","rootCause":"Binary attachment extractors, live rendered observation/repair, automatic route availability/consent, and full-library governance remain unconnected.","evidence":"Storage materialization tests plus real File-based composer cases; full 1269-test pass; production build pass.","fix":"Added the end-to-end text/data bridge: browser SHA-256, idempotent prepare/upload/register/approve, server-only storage read, UTF-8 and JSON validation, 7.2 KB bounded model preview, exact full-file digest and shape retention, immutable source revision, explicit read-context reference, and cleanup on stale authority. Binary formats remain quarantined until real extractors exist.","status":"open","ts":"2026-07-16T19:57:44.688Z"} +{"sev":"P3","area":"quote-test","symptom":"test","rootCause":"test","fix":"test","evidence":"test","status":"fixed","ts":"2026-07-16T20:01:27.862Z","fp":"1b71b4d80db0"} diff --git a/.qa/memory/runs.jsonl b/.qa/memory/runs.jsonl index 9ac4626..bdd6600 100644 --- a/.qa/memory/runs.jsonl +++ b/.qa/memory/runs.jsonl @@ -4,3 +4,15 @@ {"executor":"codex-agentic-ui-qa","app":"nodeslide","target":"final local tree; deployed mutation journey awaits isolated preview","journeys":{"fresh_landing":"PASS","model_picker":"PASS","typing_enter_shift_enter":"PASS_LOCAL_AND_PREVIEW_SPEC","file_attach_remove":"PASS","responsive_accessibility":"PASS","delete_deck":"PASS_UNIT","runtime_source":"PASS_UNIT_BUILD","deployed_editor_review":"BLOCKED_AWAITING_ISOLATED_PREVIEW"},"gates":{"unit":"551-of-551","convex_typecheck":"PASS","frontend_typecheck":"PASS","lint":"PASS","build_and_manifest":"PASS","audit_prod":"PASS","local_e2e":"6-pass-1-skipped"},"evidenceDir":"test-results (gitignored locally; uploaded by CI)","note":"Supersedes the immediately preceding run count with the final full-suite count.","ts":"2026-07-14T09:51:16.442Z"} {"ts":"2026-07-14T20:50:00.000Z","executor":"luna","app":"nodeslide","target":"current shared tree on codex/nodeslide-launch-consolidation","journeys":{"A0":"NOT_RUN_SHARED_SCOPE","A1":"NOT_RUN_SHARED_SCOPE","A2":"NOT_RUN_SHARED_SCOPE","A3":"PASS_TRACE_SCALE; OPEN_VISUAL_ARTIFACT","A4":"NOT_RUN_SHARED_SCOPE","A5":"NOT_RUN_SHARED_SCOPE","A6":"NOT_RUN_SHARED_SCOPE","A7":"PARTIAL_DEPTH_AUDIT"},"bar":{"B1":2,"B2":2,"B3":2,"B4":2,"B5":2,"B6":1,"B7":2,"B8":2,"B9":1,"B10":2,"B11":1},"gates":{"typecheck":"PASS","focusedVitest":"PASS 12 files/145 tests","traceScale":"PASS 4/4","fullVitest":"BLOCKED_SHARED_TREE 109 files/768 passed/4 failed"},"evidenceDir":"artifacts/agentic-ui-qa-2026-07-14","note":"Only owned QA scope changed; other worker application and protected matrix changes were preserved."} {"ts":"2026-07-15T23:45:00.000Z","executor":"codex-agentic-ui-qa","app":"nodeslide","target":"clean worktree codex/nodeslide-multi-agent-o11y plus live local Convex-backed editor","journeys":{"assistant_ui_thread":"PASS","viewport_footer":"PASS","keyboard_submit":"PASS_REAL_NEBIUS_RUN","session_consent":"PASS_ONE_CLICK_REUSED","nested_handoff":"PASS_READ_ONLY","reject_unchanged":"PASS","react_o11y_compact":"PASS_RECURSIVE","trace_scale":"PASS_4_10_100_1000","pixel_proof":"PASS"},"bar":{"B1":2,"B2":2,"B3":2,"B4":2,"B5":2,"B6":2,"B7":2,"B8":2,"B9":2,"B10":2,"B11":2},"gates":{"unit":"918-of-918","typecheck":"PASS","lint":"PASS_500_FILES","build":"PASS","agent_operability":"PASS_9_OF_9","trace_e2e":"PASS_4_OF_4"},"evidenceDir":"artifacts/agentic-ui-qa-2026-07-14/trace-scale","note":"Focused chat/trace Bar only; unrelated open ledger findings remain. Real editor run used Enter after one browser-tab grant, produced a validated proposal, and was rejected so the slide remained unchanged."} +{"executor":"codex","journeys":["E06-exact-slide-persuasion","A03-continue-until-presentation-ready","U04-50-message-conversation","U07-many-tool-activities","U08-mobile-long-run","F17-same-element-conflict-metadata","F18-independent-parallel-branches"],"gates":{"fixedCorpus":true,"desktopVisual":true,"mobileVisual":true,"focusedTests":"52-of-52","nodeslideSuite":"357-of-357","typecheck":true,"build":true},"evidenceDir":"active-browser-QA-session","ts":"2026-07-16T09:24:33.625Z"} +{"note":"convex dev --once used the repo configured production deployment and successfully applied evidence schema/functions/indexes; S01 made no deck mutation.","journeys":{"U08":"PASS_AFTER_NESTED_RENDERER_FIX","S01":"FAIL_UPSTREAM_WEB_SEARCH_NO_MUTATION","evidence_overlay":"PASS_COMPONENT_TESTS_NOT_LIVE_PROVIDER_PROVEN"},"app":"nodeslide","gates":{"typecheck":"BLOCKED_BY_CONCURRENT_DURABLE_SESSION_AND_BUDGET_CHANGES","capture_slice":"PASS_54_OF_54","benchmarks":"PASS_36_OF_36","full_vitest":"2_UNRELATED_STALE_REVIEW_UI_EXPECTATIONS","focused":"PASS_85_OF_85","vite_build":"PASS"},"executor":"codex","target":"NodeTrace and NodeRoom evidence port to NodeSlide","evidenceDir":"active-browser-QA-session","ts":"2026-07-16T10:16:08.601Z"} +{"executor":"codex","app":"nodeslide","target":"self-authored NodeSlide dogfood deck export","journeys":{"A1_create":"BLOCKED_AFTER_TWO_ATTEMPTS","A2_propose_review":"FAIL_SEMANTIC_COVERAGE_REJECTED","native_edit":"FAIL_NO_PERSISTENCE","A4_export_pptx":"PASS","visual_render":"PASS_7_SLIDES"},"gates":{"workflowFocused":"PASS_12_OF_12","typecheckFocused":"PASS","slidesTest":"PASS_NO_OVERFLOW","visualMontage":"PASS"},"evidenceDir":"artifacts/nodeslide-dogfood-rendered","note":"Exported the renamed editable canonical NodeSlide deck through the live NodeSlide PowerPoint export after rejecting an incorrect validated proposal; artifact is usable but slide 6 requested dogfood copy did not persist.","ts":"2026-07-16T18:05:24.191Z"} +{"executor":"codex","app":"nodeslide","target":"trace attribution, authority integrity, and live S01 evidence capture","journeys":{"trace_exact_attribution":"PASS","selection_authority":"PASS_FAIL_CLOSED","S01":"PASS_LIVE_PRODUCTION","evidence_capture":"PASS_3_READY_PDF_SNAPSHOTS","browser_visual":"BLOCKED_LOCALHOST_URL_POLICY"},"gates":{"typecheck":"PASS","fullVitest":"PASS_1192_OF_1192","build":"PASS","productionConvexDeploy":"PASS","diffCheck":"PASS"},"evidenceDir":"production-live-contract-and-component-regressions","note":"Disposable live proof deck was deleted. Browser visual reinspection could not proceed because the in-app browser URL policy rejected localhost; no alternate browser surface was used.","ts":"2026-07-16T18:14:35.351Z"} +{"executor":"codex","app":"nodeslide","target":"read-only production browser screenshot journey","journeys":{"A0_landing":"PASS","sample_desktop":"PASS","sample_mobile":"PASS","agent_chat_desktop":"PASS_WITH_P2_POLISH","agent_chat_mobile":"PASS_WITH_P2_POLISH"},"bar":{"B4":2,"B8":2,"B9":1,"B11":2},"gates":{"pixelArtifacts":"PASS_5_OF_5","charset":"UTF8","consoleErrors":0,"horizontalOverflow":false},"evidenceDir":"artifacts/browser-journey-2026-07-16","note":"Read-only production journey; no model calls, proposal acceptance, or persistent mutation.","ts":"2026-07-16T18:25:29.701Z"} +{"executor":"codex","gates":{"fullTest":"1207 passed","diffCheck":"passed","biome":"passed","typecheck":"passed","focused":"46 passed"},"evidenceDir":"convex/lib/nodeslideEditPlanner.test.ts","journeys":{"A6":"PASS semantic under-coverage corpus"},"ts":"2026-07-16T18:40:30.518Z"} +{"executor":"Codex GPT-5","gates":{"scoped_lint":"pass","repository_typecheck":"BLOCKED(unrelated active-worker errors)","focused_typecheck":"pass","diff_check":"pass","focused_tests":"21/21 pass"},"evidenceDir":"convex/lib/nodeslideSeed.test.ts","journeys":{"C01":"PASS","vague_fallback":"PASS"},"ts":"2026-07-16T18:57:14.465Z"} +{"executor":"Codex GPT-5","app":"nodeslide","target":"NodeBench-inspired model routing picker revamp","journeys":{"desktop_picker":"PASS","mobile_picker":"PASS","keyboard_and_selection_contract":"PASS"},"gates":{"focused_tests":"PASS_8_OF_8","typecheck":"PASS","build":"PASS","agent_operability":"PASS_9_OF_9","pixels":"PASS_DESKTOP_MOBILE","consoleErrors":0,"horizontalOverflow":false,"repository_tests":"1226_PASS_1_UNRELATED_TRACEWATERFALL_FAIL"},"evidenceDir":"artifacts/model-picker-revamp-2026-07-16","note":"The only repository-wide failure is in concurrently edited TraceWaterfall PDF geometry behavior, outside the model-picker change.","ts":"2026-07-16T19:00:54.123Z"} +{"executor":"codex-gpt5","journeys":{"A0":"SKIPPED(scoped polish closure)","A1":"SKIPPED(no mutation authorized)","A2":"SKIPPED(no live model call)","A3":"SKIPPED(TraceWaterfall excluded)","A4":"SKIPPED(out of scope)","A5":"PASS(local desktop and mobile pixels)","A6":"PASS(raw transport copy and long-thread regression tests)"},"bar":{"B8":2,"B9":2,"B10":2,"B11":2},"gates":"43 focused tests passed; pnpm typecheck passed; targeted Biome passed; agent-operability lint 9/9; scoped git diff --check passed","evidenceDir":"artifacts/nodeslide-polish-2026-07-16","ts":"2026-07-16T19:06:28.335Z"} +{"executor":"Codex GPT-5","journeys":{"A0":"SKIPPED no production mutation or authenticated persisted-capture fixture","A1":"SKIPPED out of scope","A2":"SKIPPED out of scope","A3":"PASS focused persisted visual evidence path","A4":"SKIPPED out of scope","A5":"PASS component resize and scroll coverage; no live pixel artifact","A6":"PASS invalid geometry and page fail-closed tests"},"bar":{"B2":2,"B5":2,"B8":2},"gates":{"focusedTests":"73 passed","typecheck":"passed","build":"passed","lint":"passed","agentUiLint":"9 of 9 passed","diffCheck":"passed"},"evidenceDir":"session test and build outputs; no production pixel artifact","ts":"2026-07-16T19:12:45.919Z"} +{"executor":"Codex GPT-5","journeys":["Advanced controls desktop light","Advanced controls desktop dark","Advanced controls mobile light","Live executor and linked-comment compatibility","Repository-wide regression suite"],"bar":{"B1":2,"B2":2,"B3":2,"B4":2,"B5":2,"B6":2,"B7":2,"B8":2,"B9":2,"B10":2,"B11":2},"depth":{"D1":"partial: live bounded inspect/measure/validate now in proposal route; render-repair remains off-route","D7":"partial: explicit durable hard spend ceiling; automatic route availability and consent not productized","D8":"open: stored binary attachment to model-read bridge remains incomplete","D11":"open: recents exist; full deck library/governance remains incomplete"},"gates":{"focused":"54/54 after compatibility additions","full":"166 files, 1265 tests","typecheck":"pass","agentUiLint":"9/9","build":"pass","pixels":"3/3, UTF-8, zero console errors, no horizontal overflow"},"evidenceDir":"artifacts/nodeslide-depth-2026-07-16","ts":"2026-07-16T19:45:54.989Z"} +{"executor":"Codex GPT-5","journeys":["Real File upload conversation cases","Two-phase stored upload policy","Stored UTF-8 materialization and source lineage","Repository-wide regression suite"],"depth":{"D8":"partial: CSV/JSON/TXT now use digest-bound Convex storage, quarantine/release, bounded model preview, exact full-file digest, source revision, and explicit read context; binary extractors remain open"},"gates":{"focused":"47/47","full":"166 files, 1269 tests","typecheck":"pass","agentUiLint":"9/9","build":"pass"},"evidenceDir":"convex/nodeslideUploads.test.ts","ts":"2026-07-16T19:57:44.534Z"} diff --git a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-10-compact.png b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-10-compact.png index 0f00113..4fa6e86 100644 Binary files a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-10-compact.png and b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-10-compact.png differ diff --git a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-compact.png b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-compact.png index e0c2a9f..b22e57e 100644 Binary files a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-compact.png and b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-compact.png differ diff --git a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-expanded.png b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-expanded.png index 1d8d198..f9eba98 100644 Binary files a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-expanded.png and b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-100-expanded.png differ diff --git a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-4-compact.png b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-4-compact.png index e2c56df..2675e72 100644 Binary files a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-4-compact.png and b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/exact-4-compact.png differ diff --git a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/high-volume-1000-expanded.png b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/high-volume-1000-expanded.png index fd78dee..375f192 100644 Binary files a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/high-volume-1000-expanded.png and b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/high-volume-1000-expanded.png differ diff --git a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/metrics.json b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/metrics.json index b6a37f1..854bd56 100644 --- a/artifacts/agentic-ui-qa-2026-07-14/trace-scale/metrics.json +++ b/artifacts/agentic-ui-qa-2026-07-14/trace-scale/metrics.json @@ -2,33 +2,33 @@ { "scenario": "exact-4", "compactDomNodes": 312, - "expandedDomNodes": 551, + "expandedDomNodes": 562, "mountedRows": 4, "totalSpans": 4 }, { "scenario": "exact-10", "compactDomNodes": 333, - "expandedDomNodes": 657, + "expandedDomNodes": 668, "mountedRows": 10, "totalSpans": 10 }, { "scenario": "exact-100", "compactDomNodes": 332, - "expandedDomNodes": 817, + "expandedDomNodes": 810, "mountedRows": 20, "totalSpans": 100 }, { "scenario": "high-volume-250-of-1000", "compactDomNodes": 339, - "expandedDomNodes": 796, + "expandedDomNodes": 792, "mountedRows": 20, "totalSpans": 1000, - "expandLatencyMs": 75.79999999701977, - "searchLatencyMs": 152.39999999850988, - "regroupLatencyMs": 283.6000000014901, + "expandLatencyMs": 96.60000000149012, + "searchLatencyMs": 75.80000000074506, + "regroupLatencyMs": 92.09999999776483, "restoredScrollTop": 1800 } ] diff --git a/artifacts/browser-journey-2026-07-16/01-production-landing-desktop.png b/artifacts/browser-journey-2026-07-16/01-production-landing-desktop.png new file mode 100644 index 0000000..9eaa7db Binary files /dev/null and b/artifacts/browser-journey-2026-07-16/01-production-landing-desktop.png differ diff --git a/artifacts/browser-journey-2026-07-16/02-production-sample-editor-desktop.png b/artifacts/browser-journey-2026-07-16/02-production-sample-editor-desktop.png new file mode 100644 index 0000000..233ae2e Binary files /dev/null and b/artifacts/browser-journey-2026-07-16/02-production-sample-editor-desktop.png differ diff --git a/artifacts/browser-journey-2026-07-16/03-production-sample-editor-mobile.png b/artifacts/browser-journey-2026-07-16/03-production-sample-editor-mobile.png new file mode 100644 index 0000000..8be8d03 Binary files /dev/null and b/artifacts/browser-journey-2026-07-16/03-production-sample-editor-mobile.png differ diff --git a/artifacts/browser-journey-2026-07-16/04-production-agent-chat-desktop.png b/artifacts/browser-journey-2026-07-16/04-production-agent-chat-desktop.png new file mode 100644 index 0000000..4d89095 Binary files /dev/null and b/artifacts/browser-journey-2026-07-16/04-production-agent-chat-desktop.png differ diff --git a/artifacts/browser-journey-2026-07-16/05-production-agent-chat-mobile.png b/artifacts/browser-journey-2026-07-16/05-production-agent-chat-mobile.png new file mode 100644 index 0000000..86adf07 Binary files /dev/null and b/artifacts/browser-journey-2026-07-16/05-production-agent-chat-mobile.png differ diff --git a/artifacts/browser-journey-2026-07-16/pixels.json b/artifacts/browser-journey-2026-07-16/pixels.json new file mode 100644 index 0000000..8f6d0ec --- /dev/null +++ b/artifacts/browser-journey-2026-07-16/pixels.json @@ -0,0 +1,89 @@ +{ + "repo": "D:/VSCode Projects/parity-studio", + "url": "https://parity-studio.vercel.app/?domain=nodeslide", + "outDir": "D:/VSCode Projects/parity-studio/artifacts/browser-journey-2026-07-16", + "assert": ["NodeSlide"], + "shots": [ + { + "name": "01-production-landing-desktop", + "viewport": { "width": 1440, "height": 900 }, + "waitMs": 1200, + "fullPage": false, + "assert": ["What presentation should we build?", "Explore the editable sample workspace"] + }, + { + "name": "02-production-sample-editor-desktop", + "viewport": { "width": 1440, "height": 900 }, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body" + ], + "waitMs": 1800, + "fullPage": false, + "assert": ["Build the story. Keep every decision editable.", "verified"] + }, + { + "name": "03-production-sample-editor-mobile", + "viewport": { "width": 390, "height": 844 }, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body" + ], + "waitMs": 1800, + "fullPage": false, + "assert": ["Build the story. Keep every decision editable.", "verified"] + }, + { + "name": "04-production-agent-chat-desktop", + "viewport": { "width": 1440, "height": 900 }, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "[data-testid=inspector-tab-ai]" + ], + "waitMs": 1800, + "fullPage": false, + "assert": ["Review before applying", "Propose"] + }, + { + "name": "05-production-agent-chat-mobile", + "viewport": { "width": 390, "height": 844 }, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "button[aria-label=\"Ask AI\"]" + ], + "waitMs": 1800, + "fullPage": false, + "assert": ["Review before applying", "Propose"] + } + ] +} diff --git a/artifacts/model-picker-revamp-2026-07-16/after-model-picker-desktop.png b/artifacts/model-picker-revamp-2026-07-16/after-model-picker-desktop.png new file mode 100644 index 0000000..666d9b8 Binary files /dev/null and b/artifacts/model-picker-revamp-2026-07-16/after-model-picker-desktop.png differ diff --git a/artifacts/model-picker-revamp-2026-07-16/after-model-picker-mobile.png b/artifacts/model-picker-revamp-2026-07-16/after-model-picker-mobile.png new file mode 100644 index 0000000..3ee4ad8 Binary files /dev/null and b/artifacts/model-picker-revamp-2026-07-16/after-model-picker-mobile.png differ diff --git a/artifacts/model-picker-revamp-2026-07-16/after-pixels.json b/artifacts/model-picker-revamp-2026-07-16/after-pixels.json new file mode 100644 index 0000000..7859fad --- /dev/null +++ b/artifacts/model-picker-revamp-2026-07-16/after-pixels.json @@ -0,0 +1,67 @@ +{ + "repo": "D:/VSCode Projects/parity-studio", + "url": "http://127.0.0.1:5174/?domain=nodeslide", + "outDir": "D:/VSCode Projects/parity-studio/artifacts/model-picker-revamp-2026-07-16", + "shots": [ + { + "name": "after-model-picker-desktop", + "viewport": { "width": 1440, "height": 900 }, + "waitMs": 2200, + "fullPage": false, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "[data-testid=inspector-tab-ai]", + "[data-testid=ai-model-select]" + ], + "assert": ["Choose the agent model", "Applies to this session", "CURRENT", "Recommended"] + }, + { + "name": "after-model-picker-mobile", + "viewport": { "width": 390, "height": 844 }, + "waitMs": 2200, + "fullPage": false, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "button[aria-label=\"Ask AI\"]", + "[data-testid=ai-model-select]" + ], + "assert": ["Choose the agent model", "Applies to this session", "CURRENT", "Recommended"] + } + ] +} diff --git a/artifacts/model-picker-revamp-2026-07-16/before-model-picker-desktop.png b/artifacts/model-picker-revamp-2026-07-16/before-model-picker-desktop.png new file mode 100644 index 0000000..7a81944 Binary files /dev/null and b/artifacts/model-picker-revamp-2026-07-16/before-model-picker-desktop.png differ diff --git a/artifacts/model-picker-revamp-2026-07-16/before-model-picker-mobile.png b/artifacts/model-picker-revamp-2026-07-16/before-model-picker-mobile.png new file mode 100644 index 0000000..f8874aa Binary files /dev/null and b/artifacts/model-picker-revamp-2026-07-16/before-model-picker-mobile.png differ diff --git a/artifacts/model-picker-revamp-2026-07-16/before-pixels.json b/artifacts/model-picker-revamp-2026-07-16/before-pixels.json new file mode 100644 index 0000000..78029c0 --- /dev/null +++ b/artifacts/model-picker-revamp-2026-07-16/before-pixels.json @@ -0,0 +1,47 @@ +{ + "repo": "D:/VSCode Projects/parity-studio", + "url": "https://parity-studio.vercel.app/?domain=nodeslide", + "outDir": "D:/VSCode Projects/parity-studio/artifacts/model-picker-revamp-2026-07-16", + "shots": [ + { + "name": "before-model-picker-desktop", + "viewport": { "width": 1440, "height": 900 }, + "waitMs": 1800, + "fullPage": false, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "[data-testid=inspector-tab-ai]", + "[data-testid=ai-model-select]" + ], + "assert": ["Agent model", "Recommended", "Private fallback"] + }, + { + "name": "before-model-picker-mobile", + "viewport": { "width": 390, "height": 844 }, + "waitMs": 1800, + "fullPage": false, + "clicks": [ + "text=Explore the editable sample workspace", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "body", + "button[aria-label=\"Ask AI\"]", + "[data-testid=ai-model-select]" + ], + "assert": ["Agent model", "Recommended", "Private fallback"] + } + ] +} diff --git a/artifacts/nodeslide-authoring-journey-2026-07-16/browser-journey.gif b/artifacts/nodeslide-authoring-journey-2026-07-16/browser-journey.gif new file mode 100644 index 0000000..fe6b1ec Binary files /dev/null and b/artifacts/nodeslide-authoring-journey-2026-07-16/browser-journey.gif differ diff --git a/artifacts/nodeslide-authoring-journey-2026-07-16/browser-journey.webm b/artifacts/nodeslide-authoring-journey-2026-07-16/browser-journey.webm new file mode 100644 index 0000000..41ef3d7 Binary files /dev/null and b/artifacts/nodeslide-authoring-journey-2026-07-16/browser-journey.webm differ diff --git a/artifacts/nodeslide-authoring-journey-2026-07-16/final-editor.png b/artifacts/nodeslide-authoring-journey-2026-07-16/final-editor.png new file mode 100644 index 0000000..0ad96e8 Binary files /dev/null and b/artifacts/nodeslide-authoring-journey-2026-07-16/final-editor.png differ diff --git a/artifacts/nodeslide-authoring-journey-2026-07-16/journey-proof.json b/artifacts/nodeslide-authoring-journey-2026-07-16/journey-proof.json new file mode 100644 index 0000000..3c8dee5 --- /dev/null +++ b/artifacts/nodeslide-authoring-journey-2026-07-16/journey-proof.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": "nodeslide.journey-proof/v1", + "deckId": "deck_job_a4bfcd910cfd3eac63e3fd8df6f2596f", + "expectedCreationProvenance": "brief_to_new_deck", + "actualCreationProvenance": "brief_to_new_deck", + "baseVersion": 1, + "acceptedVersion": 2, + "steps": [ + { + "kind": "brief_submitted", + "occurredAt": 1784231834957 + }, + { + "kind": "deck_created", + "occurredAt": 1784231839460, + "deckVersion": 1 + }, + { + "kind": "proposal_ready", + "occurredAt": 1784231844146, + "deckVersion": 1 + }, + { + "kind": "compare_opened", + "occurredAt": 1784231844222, + "deckVersion": 1 + }, + { + "kind": "validation_received", + "occurredAt": 1784231844267, + "deckVersion": 1, + "receiptDigest": "sha256:0c8a7e55cce7183e7a023b72cc790a8d432258cf467e5514d84140bce9c250ca" + }, + { + "kind": "proposal_accepted", + "occurredAt": 1784231845671, + "receiptDigest": "sha256:b8579584e6bc99e4653657c0d05da193af08df832b9693aa49f2e42a7255392c" + }, + { + "kind": "version_advanced", + "occurredAt": 1784231846115, + "deckVersion": 2 + }, + { + "kind": "export_downloaded", + "occurredAt": 1784231846749, + "deckVersion": 2, + "artifactPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\nodeslide-self-authored.pptx" + } + ], + "artifacts": { + "rawRecordingPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\browser-journey.webm", + "gifPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\browser-journey.gif", + "finalScreenshotPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\final-editor.png", + "exportedDeckPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\nodeslide-self-authored.pptx", + "runManifestPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\run-manifest.json" + }, + "digest": "sha256:5b10c0f985d9d69b3fd124a21b130d620e8926e8d05f5aad7159a74d5f0d7a12" +} diff --git a/artifacts/nodeslide-authoring-journey-2026-07-16/nodeslide-self-authored.pptx b/artifacts/nodeslide-authoring-journey-2026-07-16/nodeslide-self-authored.pptx new file mode 100644 index 0000000..bf5928a Binary files /dev/null and b/artifacts/nodeslide-authoring-journey-2026-07-16/nodeslide-self-authored.pptx differ diff --git a/artifacts/nodeslide-authoring-journey-2026-07-16/run-manifest.json b/artifacts/nodeslide-authoring-journey-2026-07-16/run-manifest.json new file mode 100644 index 0000000..f858ca8 --- /dev/null +++ b/artifacts/nodeslide-authoring-journey-2026-07-16/run-manifest.json @@ -0,0 +1,57 @@ +{ + "deckId": "deck_job_a4bfcd910cfd3eac63e3fd8df6f2596f", + "expectedCreationProvenance": "brief_to_new_deck", + "actualCreationProvenance": "brief_to_new_deck", + "baseVersion": 1, + "acceptedVersion": 2, + "steps": [ + { + "kind": "brief_submitted", + "occurredAt": 1784231834957 + }, + { + "kind": "deck_created", + "occurredAt": 1784231839460, + "deckVersion": 1 + }, + { + "kind": "proposal_ready", + "occurredAt": 1784231844146, + "deckVersion": 1 + }, + { + "kind": "compare_opened", + "occurredAt": 1784231844222, + "deckVersion": 1 + }, + { + "kind": "validation_received", + "occurredAt": 1784231844267, + "deckVersion": 1, + "receiptDigest": "sha256:0c8a7e55cce7183e7a023b72cc790a8d432258cf467e5514d84140bce9c250ca" + }, + { + "kind": "proposal_accepted", + "occurredAt": 1784231845671, + "receiptDigest": "sha256:b8579584e6bc99e4653657c0d05da193af08df832b9693aa49f2e42a7255392c" + }, + { + "kind": "version_advanced", + "occurredAt": 1784231846115, + "deckVersion": 2 + }, + { + "kind": "export_downloaded", + "occurredAt": 1784231846749, + "deckVersion": 2, + "artifactPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\nodeslide-self-authored.pptx" + } + ], + "artifacts": { + "rawRecordingPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\browser-journey.webm", + "gifPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\browser-journey.gif", + "finalScreenshotPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\final-editor.png", + "exportedDeckPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\nodeslide-self-authored.pptx", + "runManifestPath": "D:\\VSCode Projects\\parity-studio\\artifacts\\nodeslide-authoring-journey-2026-07-16\\run-manifest.json" + } +} diff --git a/artifacts/nodeslide-depth-2026-07-16/advanced-budget-desktop-dark.png b/artifacts/nodeslide-depth-2026-07-16/advanced-budget-desktop-dark.png new file mode 100644 index 0000000..8f915fb Binary files /dev/null and b/artifacts/nodeslide-depth-2026-07-16/advanced-budget-desktop-dark.png differ diff --git a/artifacts/nodeslide-depth-2026-07-16/advanced-budget-desktop-light.png b/artifacts/nodeslide-depth-2026-07-16/advanced-budget-desktop-light.png new file mode 100644 index 0000000..a053cc4 Binary files /dev/null and b/artifacts/nodeslide-depth-2026-07-16/advanced-budget-desktop-light.png differ diff --git a/artifacts/nodeslide-depth-2026-07-16/advanced-budget-mobile-light.png b/artifacts/nodeslide-depth-2026-07-16/advanced-budget-mobile-light.png new file mode 100644 index 0000000..b7f33c8 Binary files /dev/null and b/artifacts/nodeslide-depth-2026-07-16/advanced-budget-mobile-light.png differ diff --git a/artifacts/nodeslide-depth-2026-07-16/declutter-ledger.md b/artifacts/nodeslide-depth-2026-07-16/declutter-ledger.md new file mode 100644 index 0000000..2f27b71 --- /dev/null +++ b/artifacts/nodeslide-depth-2026-07-16/declutter-ledger.md @@ -0,0 +1,10 @@ +# Advanced-controls declutter ledger + +| id | selector/component | user promise | capability guard | backing field/action | observed artifact | disposition | preserve/reverify assertion | +|---|---|---|---|---|---|---|---| +| C1 | `.ns-ai-turbo-details` status | Exact session change authority | `PRESERVE_CAPABILITY` | `approvalMode`, `approvalExpiresAt`, `onApprovalModeChange` | Desktop/mobile advanced-control pixels | COMPACT | `Session change authority` and active/off state remain visible | +| C2 | authority limit paragraph | Expiry, use limit, operation limit, and exclusions | `PRESERVE_CAPABILITY` | delegation constants | Paragraph occupies most of the overlay before controls | DEFER | Named `Limits and exclusions` disclosure reveals all constants and excluded actions | +| C3 | `.ns-scope-row` | Exact write scope | `PRESERVE_CAPABILITY` | `scopeChoice` and `chooseScope` | Desktop/mobile advanced-control pixels | PRESERVE | Deck / This slide / Selection remain reachable | +| C4 | `.ns-ai-policy-grid select` | Operation, design, and reference policies | `PRESERVE_CAPABILITY` | component state included in request | Desktop/mobile advanced-control pixels | PRESERVE | All three selects remain enabled and labeled | +| C5 | `[data-testid="ai-run-spend-limit"]` | Hard run spend ceiling | `PRESERVE_CAPABILITY` | `maxCostUsd` durable request field | New capability in this pass | PRESERVE | Field remains labeled and submit binds numeric value | +| C6 | route summary consent state | Provider/model/effort/consent truth | `PRESERVE_CAPABILITY` | provider selection and session consent | Desktop/mobile advanced-control pixels | PRESERVE | Provider, model, effort, and consent-required state remain visible | diff --git a/artifacts/nodeslide-depth-2026-07-16/pixels.json b/artifacts/nodeslide-depth-2026-07-16/pixels.json new file mode 100644 index 0000000..6041ce6 --- /dev/null +++ b/artifacts/nodeslide-depth-2026-07-16/pixels.json @@ -0,0 +1,43 @@ +{ + "repo": "D:/VSCode Projects/parity-studio", + "url": "http://127.0.0.1:5174/", + "outDir": "D:/VSCode Projects/parity-studio/artifacts/nodeslide-depth-2026-07-16", + "assert": ["NodeSlide", "Run spend ceiling", "Optional hard USD cap"], + "shots": [ + { + "name": "advanced-budget-desktop-light", + "viewport": { "width": 1512, "height": 982 }, + "clicks": [ + "text=Explore the editable sample workspace", + "button[aria-label='Expand composer']", + "[data-testid=ai-provider-summary]" + ], + "waitMs": 900, + "fullPage": false + }, + { + "name": "advanced-budget-desktop-dark", + "viewport": { "width": 1512, "height": 982 }, + "clicks": [ + "text=Explore the editable sample workspace", + "button[aria-label='Switch to dark theme']", + "button[aria-label='Expand composer']", + "[data-testid=ai-provider-summary]" + ], + "waitMs": 900, + "fullPage": false + }, + { + "name": "advanced-budget-mobile-light", + "viewport": { "width": 390, "height": 844 }, + "clicks": [ + "text=Explore the editable sample workspace", + "button[aria-label='Ask AI']", + "button[aria-label='Expand composer']", + "[data-testid=ai-provider-summary]" + ], + "waitMs": 900, + "fullPage": false + } + ] +} diff --git a/artifacts/nodeslide-dogfood-montage.png b/artifacts/nodeslide-dogfood-montage.png new file mode 100644 index 0000000..b39a3b0 Binary files /dev/null and b/artifacts/nodeslide-dogfood-montage.png differ diff --git a/artifacts/nodeslide-dogfood-rendered/slide-1.png b/artifacts/nodeslide-dogfood-rendered/slide-1.png new file mode 100644 index 0000000..495b291 Binary files /dev/null and b/artifacts/nodeslide-dogfood-rendered/slide-1.png differ diff --git a/artifacts/nodeslide-dogfood-rendered/slide-2.png b/artifacts/nodeslide-dogfood-rendered/slide-2.png new file mode 100644 index 0000000..a47d09a Binary files /dev/null and b/artifacts/nodeslide-dogfood-rendered/slide-2.png differ diff --git a/artifacts/nodeslide-dogfood-rendered/slide-3.png b/artifacts/nodeslide-dogfood-rendered/slide-3.png new file mode 100644 index 0000000..b388d8e Binary files /dev/null and b/artifacts/nodeslide-dogfood-rendered/slide-3.png differ diff --git a/artifacts/nodeslide-dogfood-rendered/slide-4.png b/artifacts/nodeslide-dogfood-rendered/slide-4.png new file mode 100644 index 0000000..b8f50e7 Binary files /dev/null and b/artifacts/nodeslide-dogfood-rendered/slide-4.png differ diff --git a/artifacts/nodeslide-dogfood-rendered/slide-5.png b/artifacts/nodeslide-dogfood-rendered/slide-5.png new file mode 100644 index 0000000..e63507b Binary files /dev/null and b/artifacts/nodeslide-dogfood-rendered/slide-5.png differ diff --git a/artifacts/nodeslide-dogfood-rendered/slide-6.png b/artifacts/nodeslide-dogfood-rendered/slide-6.png new file mode 100644 index 0000000..5d8456a Binary files /dev/null and b/artifacts/nodeslide-dogfood-rendered/slide-6.png differ diff --git a/artifacts/nodeslide-dogfood-rendered/slide-7.png b/artifacts/nodeslide-dogfood-rendered/slide-7.png new file mode 100644 index 0000000..54734fe Binary files /dev/null and b/artifacts/nodeslide-dogfood-rendered/slide-7.png differ diff --git a/artifacts/nodeslide-dogfood-self-authored-2026-07-16.pptx b/artifacts/nodeslide-dogfood-self-authored-2026-07-16.pptx new file mode 100644 index 0000000..12dce9e Binary files /dev/null and b/artifacts/nodeslide-dogfood-self-authored-2026-07-16.pptx differ diff --git a/artifacts/nodeslide-openui-eve-latest-hierarchical-checklist-2026-07-15.md b/artifacts/nodeslide-openui-eve-latest-hierarchical-checklist-2026-07-15.md new file mode 100644 index 0000000..ef70d8f --- /dev/null +++ b/artifacts/nodeslide-openui-eve-latest-hierarchical-checklist-2026-07-15.md @@ -0,0 +1,489 @@ +# NodeSlide × OpenUI × Eve — latest-state hierarchical summary checklist + +Date: 2026-07-15 (America/Los_Angeles) +Repository: `D:\VSCode Projects\parity-studio` +Mode: read-only product/repository diagnostic; no production mutation, live-model call, or deployment + +## Status legend + +- [x] **PRESENT** — evidenced in the current working tree or current first-party documentation. +- [ ] **PARTIAL** — useful substrate exists, but the requested capability is incomplete. +- [ ] **MISSING** — no implementation or proof was found. +- [ ] **BLOCKED** — a prerequisite prevents an honest pass. + +The working tree was already dirty and changed during inspection. This report maps the latest state +observed after the 2026-07-15 22:37 full test run; it does not claim that the uncommitted work is +reviewed, committed, deployed, or live. + +## 0. Executive decision + +- [x] **ADOPT:** use [Thesys OpenUI](https://github.com/thesysdev/openui) / OpenUI Lang as + NodeSlide's interactive visual-research, material-selection, data-exploration, QA, and trace + workbench. +- [x] **DO NOT ADOPT FOR THIS ROLE:** [W&B OpenUI](https://github.com/wandb/openui) is a + prompt-to-UI prototyping application, not the intended NodeSlide agent-tool runtime. +- [x] **KEEP:** SlideLang remains the canonical presentation document, rendering, validation, HTML, + and editable PowerPoint format. +- [x] **KEEP:** Convex remains authoritative for decks, sources, patches, versions, review decisions, + traces, and durable hosted jobs. +- [x] **KEEP:** NodeSlide's planner/provider layer remains responsible for research and proposal + planning. The repo uses `@earendil-works/pi-ai`; it does not yet prove a full iterative Pi agent + tool loop. +- [x] **KEEP:** Eve remains an optional external operator shell for sessions, channels, schedules, + connections, subagents, and sandboxed work. It must not become a second deck-state authority. +- [ ] **GO only for a compatibility spike:** do not present OpenUI or Eve as a production NodeSlide + capability until the deterministic hero flow and a disposable staging flow pass the release bar in + section 8. + +### Target authority flow + +```mermaid +flowchart TD + A["User or Eve channel"] --> B["NodeSlide planner / governed tool registry"] + B --> C["OpenUI visual workbench"] + C --> D["Structured action event"] + D --> E["VisualMaterialSpec"] + E --> F["SlideLang candidate compiler"] + F --> G["Validation + candidate digest"] + G --> H["Reviewable DeckPatch"] + H --> I{"Human review"} + I -->|Accept| J["Convex canonical version"] + I -->|Reject| K["Deck unchanged"] + J --> L["HTML + editable PPTX"] +``` + +## 1. External research mapped to current versions + +### 1.1 Thesys OpenUI + +- [x] **PRESENT:** `@openuidev/react-lang` is currently `0.2.8`, MIT licensed, and compatible with + React 18.3/19 and Zod 3.25/4. The repo already uses React 19 and Zod 4, so the compatibility + baseline is good. Evidence: [npm package](https://www.npmjs.com/package/@openuidev/react-lang). +- [x] **PRESENT:** `@openuidev/react-ui` is currently `0.12.1`, MIT licensed, but brings a broad + generic UI stack including Radix packages, Recharts, Zustand, markdown, math, and its own Lucide + range. Evidence: [npm package](https://www.npmjs.com/package/@openuidev/react-ui). +- [x] **DECISION:** start with exact-pinned `@openuidev/react-lang@0.2.8` plus the repo's existing + Zod and NodeSlide design system. Do not import `@openuidev/react-ui` in phase 0. +- [x] **PRESENT:** OpenUI Lang's current documented language is v0.5: streamed assignment statements, + reactive state, `Query`, `Mutation`, `@Run`, bindings, and incremental edit mode. Evidence: + [v0.5 specification](https://www.openui.com/docs/openui-lang/specification-v05). +- [x] **PRESENT:** the renderer accepts either a function map or an MCP client as its tool provider; + reactive queries can re-run without another model call. Evidence: + [Queries & Mutations](https://www.openui.com/docs/openui-lang/queries-mutations). +- [x] **PRESENT:** incremental editing replaces same-name statements, adds new statements, and keeps + omitted statements, which matches NodeSlide's no-clobber direction. Evidence: + [Incremental Editing](https://www.openui.com/docs/openui-lang/incremental-editing). +- [x] **PRESENT:** the renderer exposes structured parser, query, mutation, runtime, and render errors, + including `unknown-component`, `missing-required`, `tool-not-found`, `runtime-error`, and + `render-error`. Evidence: [Renderer](https://www.openui.com/docs/openui-lang/renderer). +- [ ] **PARTIAL:** OpenUI's published token-efficiency figures are first-party benchmark results, not + NodeSlide evidence. Reproduce them with NodeSlide components and deck tasks before repeating a + public efficiency claim. +- [ ] **UNVERIFIED:** no current first-party OpenUI documentation was found for the pasted brief's + specific “persistent Pi coding-agent session” example. Treat Pi alignment as architectural + compatibility, not an OpenUI-supported integration claim. + +### 1.2 Vercel Eve + +- [x] **PRESENT:** Eve's current package version is `0.24.4`; the experiment pins that exact version. +- [x] **PRESENT:** Vercel's current Eve model still uses `instructions.md`, optional `agent.ts`, + filesystem-discovered `skills/` and `tools/`, plus `sandbox/`, `channels/`, `connections/`, + `subagents/`, and `schedules/`. Evidence: [Vercel Eve](https://vercel.com/eve). +- [x] **PRESENT:** Eve currently requires Node.js 24 or newer. +- [ ] **BLOCKED:** the inspected shell is Node `22.22.2`; the Eve TypeScript check passes, but an + honest runtime/dev proof requires Node 24. + +## 2. Current NodeSlide substrate + +### 2.1 Canonical presentation and governance + +- [x] **PRESENT:** SlideLang supports text, shape, image, chart, math, video, and connector elements + (`shared/nodeslide.ts:139`, `shared/nodeslide.ts:223-289`). +- [x] **PRESENT:** HTML/PPTX capability reporting and compilation remain owned by the SlideLang + adapter (`src/domains/nodeslide/slidelang/types.ts:14-113`). +- [x] **PRESENT:** scoped patch operations, CAS clocks, candidate digests, candidate validation, and + reviewable patch status already exist (`shared/nodeslide.ts:348-466`). +- [x] **PRESENT:** source records include URL/citation, license, content digest, format, retention, + refresh status, and retrieval time (`shared/nodeslide.ts:469-488`). +- [x] **PRESENT:** traces include plan, context, tool calls, guardrails, provider/model, cost, tokens, + validation, and candidate digest (`shared/nodeslide.ts:574-597`). +- [x] **PRESENT:** candidate acceptance/rejection is separate from proposal creation; the new shared + agent client checks exact reviewed digest/base version, verifies reject leaves the version + unchanged, and verifies accept creates exactly one new deck version + (`packages/nodeslide-agent-client/src/client.ts:110-200`). + +### 2.2 Product surfaces OpenUI can inhabit + +- [x] **PRESENT:** the inspector already has AI, Design, Comments, Versions, Data, and Trace tabs + (`src/domains/nodeslide/inspector/types.ts:1` and + `src/domains/nodeslide/inspector/InspectorPanel.tsx:123-130`). +- [x] **PRESENT:** AI already exposes scoped proposals, sources/data attachments, variations, + candidate validation, preview/compare, and accept/reject handlers. +- [x] **PRESENT:** Overview and Compare modes already provide narrative and candidate-comparison + hosts (`src/domains/nodeslide/NodeSlideStudio.tsx`). +- [ ] **PARTIAL:** those surfaces use hand-authored React UI. No OpenUI renderer, library, program, + action adapter, or persisted OpenUI artifact exists. + +### 2.3 Existing validation versus the proposed visual workbench + +- [x] **PRESENT:** deterministic validation checks structure, geometry, overflow, collisions, + contrast, font size, source coverage, safe media URLs, export capability, and on-brand rules + (`src/domains/nodeslide/slidelang/validation.ts:650-710`). +- [x] **PRESENT:** chart validation checks that each series has one finite value per label and that + source-worthy charts have source bindings (`validation.ts:289-314`, `validation.ts:507-568`). +- [ ] **MISSING:** semantic chart audits do not check mixed units, misleading shared axes, scale + selection, actual-versus-forecast status, or scenario classification. +- [ ] **MISSING:** `ChartSeries` has no per-series unit; `ChartData` has only one optional shared unit + (`shared/nodeslide.ts:223-235`). This cannot honestly model the proposed copies/speed/labor/progress + comparison on a common axis. +- [ ] **MISSING:** there is no first-class claim ledger or claim classification + (`observed | forecast | scenario | branch | synthesis`). +- [ ] **PARTIAL:** density, variation, preference, StoryBench, and taste-mismatch infrastructure + exists, but there is no unified `DensityBudget`, `ClutterAudit`, `HierarchyAudit`, or + `DeckRhythmAudit` contract exposed to an interactive workbench. + +## 3. Proposed OpenUI component groups mapped to the latest repo + +### 3.1 Evidence and sources + +- [x] **PRESENT substrate:** `SourceRecord`, Data inspector, uploaded source lifecycle, web-source + snapshots, and element-level `sourceIds`. +- [ ] **MISSING:** `ClaimCard`, `ClaimLedger`, `EvidenceMatrix`, `SourceFigure`, `SourceExcerpt`, + `ClaimClassLegend`, `UncertaintyNote`, and `SourceTimeline` components and contracts. +- [ ] **MISSING:** figure-level rights status, locator/crop metadata, and claim-to-figure lineage. + +### 3.2 Visual materials + +- [x] **PRESENT substrate:** chart/image/video/math/connector elements, SlideLang render/export, and + slide variations. +- [ ] **MISSING:** a reusable visual-material registry and semantic archetypes such as + `ForecastRange`, `ComputeLadder`, `BottleneckFunnel`, `CausalLoop`, `DecisionFork`, and + `ScenarioStateExplorer`. +- [ ] **MISSING:** a saved visual recipe and a stable bridge from selected workbench material to + editable SlideLang elements. + +### 3.3 Chart and data review + +- [x] **PRESENT substrate:** chart elements, source binding, finite-array validation, Data inspector, + and analysis-kernel/StoryBench code. +- [ ] **MISSING:** `ChartCandidate`, `ChartCandidateCompare`, `ChartDataTable`, `AxisInspector`, + `ScaleWarning`, `UnitBadge`, `ScenarioDataBadge`, and `SourceCoverage` components. +- [ ] **MISSING:** per-series units and a deterministic incompatible-unit/shared-axis gate. + +### 3.4 Images and video + +- [x] **PRESENT substrate:** image metadata, alt text, video URLs/posters/captions, safe URL checks, + and explicit PowerPoint export capability reporting. +- [ ] **MISSING:** candidate grid, rights badge, source cropper, focal point, storyboard, poster-frame + selection, motion plan, and reduced-motion/PPTX fallback authoring surfaces. + +### 3.5 Narrative and slide planning + +- [x] **PRESENT substrate:** deck brief, Story Arc overview, variations with content angle/density/ + layout archetype, notes, and slide-level updates. +- [ ] **MISSING:** a first-class `SlideContract` binding job, takeaway, evidence, density, visual + archetype, and audience question. +- [ ] **MISSING:** `NarrativeDirectionCompare`, `SlideRhythmBoard`, and `TransitionAudit` workbench + components. + +### 3.6 Density and visual QA + +- [x] **PRESENT substrate:** deterministic validation, taste mismatch, StoryBench, signature + evidence, overflow/contrast/export gates, and proof scripts. +- [ ] **MISSING:** streamed workbench components for density budget, competing visual centers, + hierarchy, clutter, citation coverage, slide QA, and deck rhythm. +- [ ] **MISSING:** a visual-center or mixed-unit result that blocks `Use on slide` before proposal. + +### 3.7 Review and authoring actions + +- [x] **PRESENT substrate:** reviewable proposal, compare, accept, reject, comment, propagation, + digest binding, CAS clocks, versions, restore, and receipts. +- [ ] **MISSING:** OpenUI action events for `UseOnSlide`, `PreviewOnSlide`, `AttachEvidence`, + `SaveVisualRecipe`, `CreateSlideVariant`, `ApplyAcrossDeck`, `SendToComments`, and + `RequestHumanReview`. +- [ ] **REQUIRED invariant:** every generated action emits structured intent. It must never call a + raw deck mutation or imply that selection equals acceptance. + +## 4. Required contract and file hierarchy + +### 4.1 Canonical contracts + +- [ ] Create `shared/nodeslideVisualMaterials.ts` with bounded, validator-backed versions of: + - [ ] `OpenUIArtifact` — program, language/library/tool digests, state, source/claim/material IDs, + structured errors, status, render digest, timestamps, and bounded retention metadata. + - [ ] `VisualMaterialSpec` — role, semantic purpose, archetype, per-series units, graph data, + source/claim IDs, claim class, density, web/PPTX capability, fallback, and originating OpenUI + artifact ID. + - [ ] `SlideContract` — audience job, takeaway, evidence requirements, visual intent, density, and + slide scope. + - [ ] `OpenUIActionIntent` — action name, artifact/material ID, exact deck/slide scope, base clocks, + and idempotency key. +- [ ] Add explicit schema versions and hard bounds for program bytes, statement count, component + count, state bytes, query rows, source/claim/material IDs, and stored errors. +- [ ] Extend chart data so each series can declare a unit, while preserving backward compatibility + for the current shared chart unit. + +### 4.2 Product implementation + +- [ ] Create the phase-0 module: + + ```text + src/domains/nodeslide/openui/ + ├── library.ts + ├── prompt.ts + ├── renderer.tsx + ├── toolProvider.ts + ├── actions.ts + ├── persistence.ts + ├── types.ts + └── components/ + ├── LucidSummary.tsx + ├── EvidenceGroup.tsx + ├── ClaimRef.tsx + ├── ChartCandidateCompare.tsx + ├── ClutterAudit.tsx + └── UseOnSlide.tsx + ``` + +- [ ] Add server-owned tools and persistence behind Convex functions; keep provider keys and owner + capabilities out of browser-rendered programs and component props. +- [ ] Register task-specific libraries (`evidence`, `chart`, `media`, `narrative`, `qa`) rather than + injecting every component into every prompt. +- [ ] Add OpenUI workbench hosts incrementally: + - [ ] AI tab first: visual research, comparisons, proposal summary. + - [ ] Data tab second: reactive filters and tables. + - [ ] Overview/Compare third: rhythm and candidate comparisons. + - [ ] Trace tab last: persisted program/tool/action/repair provenance. + +## 5. Tool and transport checklist + +### 5.1 Current transport reality + +- [x] **PRESENT:** NodeSlide MCP exposes governed read, source, web research, deck creation, + propose, trace, versions, accept, and reject tools (`docs/nodeslide-mcp.md:73-100`). +- [x] **PRESENT:** MCP is local stdio only; the repo explicitly withholds hosted Streamable HTTP + until OAuth 2.1, scoped tokens, revocation, and provenance/metering headers exist + (`docs/nodeslide-mcp.md:1-12`). +- [x] **PRESENT:** the new `@parity/nodeslide-agent-client` centralizes a typed Convex HTTP adapter + reused by MCP and Eve. +- [ ] **PARTIAL:** Eve currently uses the typed API path directly, not NodeSlide MCP. That is allowed + by the proposed “MCP/API” architecture, but an Eve→MCP trace cannot yet be claimed. +- [ ] **BLOCKED:** a browser OpenUI renderer cannot use the local stdio MCP server directly. + +### 5.2 Recommended phase-0 transport + +- [ ] Use an allowlisted **server-owned function map** as OpenUI's `toolProvider`. +- [ ] Derive tool names, descriptions, schemas, and execution adapters from one registry so prompt + generation and runtime execution cannot drift. +- [ ] Route browser actions through existing authenticated Convex queries/actions/mutations. +- [ ] Keep raw owner capability and provider credentials server-side. +- [ ] Add authenticated Streamable HTTP MCP only as a later adapter after the repo's documented auth, + revocation, scope, and metering prerequisites exist. + +### 5.3 Tool hierarchy + +- [ ] **Read-only queries:** deck context, slide contract, selected elements, sources, claims, + figures, visual materials, density budget, and export capabilities. +- [ ] **Analysis:** claim classification, chart extraction, visual-archetype comparison, chart + semantics, clutter, hierarchy, and source coverage. +- [ ] **Render:** visual candidate, slide preview, before/after, and deck overview. +- [ ] **Proposal only:** create `VisualMaterialSpec`, compile material to SlideLang, propose slide + patch, and propose deck propagation. +- [ ] **Approval-gated mutations only:** accept/reject patch, save recipe, attach comment. +- [ ] **Never expose:** raw table writes, arbitrary URL fetch, arbitrary code execution, owner keys, + provider keys, direct patch application, publish/share/delete, or silent cross-deck propagation. + +## 6. Eve operator mapped to the current tree + +### 6.1 Present now + +- [x] `experiments/eve-nodeslide-operator/` exists and pins Eve `0.24.4`. +- [x] `agent/instructions.md` defines identity, authority, inspect-first behavior, narrow scope, + proposal-before-mutation, exact digest/version review, and fail-closed behavior. +- [x] `agent/agent.ts` selects `openai/gpt-5.4-mini`. +- [x] `agent/skills/revise-slide.md` implements the narrow revise/review procedure. +- [x] Thin tools exist for `inspect_deck`, `propose_edit`, `accept_proposal`, and + `reject_proposal`; accept/reject use Eve approval gates. +- [x] The shared agent-client tests cover deterministic proposal immutability, digest mismatch, + exact one-version acceptance, and unchanged-version rejection. +- [x] `.env.example` names configuration without committing secret values. + +### 6.2 Still missing + +- [ ] **BLOCKED:** run the experiment under Node 24; current Node 22 only typechecks it. +- [ ] **MISSING:** a disposable live/staging proof of inspect → propose → review → accept/reject → + resulting version. +- [ ] **MISSING:** a trace that identifies Eve, transport, model, proposal, candidate digest, + validation, human review, and resulting version. +- [ ] **MISSING:** `connections/`, `channels/`, `schedules/`, `subagents/`, `sandbox/`, and `evals/`. +- [ ] **MISSING skills:** create deck, research claims, build chart, visual review, refresh deck, and + export deck. +- [ ] **MISSING tools:** source ingestion, claim/figure reads, visual-material inspection, export, + and stale-source refresh. +- [ ] **MISSING:** the proposed visual-critic subagent and 10-case Eve dogfood evaluation. +- [ ] **DEFER intentionally:** Slack, schedules, and specialist subagents until the first governed + staging proof passes. + +### 6.3 Recommended next Eve additions after OpenUI phase 0 + +- [ ] Add `agent/skills/visual-review.md` that loads only for visual-material work. +- [ ] Add read-only `inspect_visual_material.ts` and `inspect_openui_artifact.ts` tools. +- [ ] Add proposal-only `propose_visual_material.ts`; do not give the subagent accept authority. +- [ ] Add `agent/subagents/visual-critic/` with only inspection/audit/render tools and structured + output. +- [ ] Add the first 10 eval cases before Slack or schedules: + - [ ] deterministic headline edit; + - [ ] exact element-scope edit; + - [ ] source-backed chart; + - [ ] mixed-unit chart rejection; + - [ ] claim-lineage preservation; + - [ ] visual candidate comparison; + - [ ] stale version rejection; + - [ ] double-submit/idempotency; + - [ ] export capability/fallback; + - [ ] OpenUI selection remains unapplied until explicit review. + +## 7. OpenUI rollout checklist + +### Phase 0 — compatibility and AI 2027 hero spike + +- [ ] Pin `@openuidev/react-lang@0.2.8`; record OpenUI Lang v0.5 as the generated-program language. +- [ ] Implement the four canonical contracts in section 4.1. +- [ ] Implement only six components: `LucidSummary`, `EvidenceGroup`, `ClaimRef`, + `ChartCandidateCompare`, `ClutterAudit`, and `UseOnSlide`. +- [ ] Implement only read-only tool calls plus structured `UseOnSlide` action intent. +- [ ] Use a deterministic AI 2027 slide-8 fixture with four claims in incompatible units. +- [ ] Compare a misleading common-axis bar candidate with a valid transformation ladder. +- [ ] Make the deterministic chart-semantics gate reject the common-axis option. +- [ ] Convert the selected ladder to a `VisualMaterialSpec` with exact claim/source bindings. +- [ ] Compile to editable SlideLang objects, render the affected slide, validate it, and return an + unapplied DeckPatch with identical before/after versions. +- [ ] Stop for review; accept must create exactly one version and reject must leave it unchanged. + +### Phase 0 pass evidence + +- [ ] Stored OpenUI program + library/tool/prompt digests. +- [ ] Structured parse/render/tool errors and bounded one-statement repair evidence. +- [ ] Screenshot/video of progressive stream, candidate comparison, selection, SlideLang preview, + review, and final receipt. +- [ ] Candidate validation including sources, units, overflow, clutter, and PPTX capability. +- [ ] Native PPTX inspection proving the selected visual remains editable. +- [ ] Trace binds OpenUI artifact → action → material spec → patch → review → version. + +### Phase 1 — data and sources + +- [ ] Add `SourceCard`, `SourceFigure`, `ForecastChart`, `ChartDataTable`, and `ClaimLedger`. +- [ ] Add typed claims and figures with retrieval/source/rights/classification lineage. +- [ ] Connect read-only data tools through the same registry used by OpenUI prompts. +- [ ] Persist state snapshots and allow deterministic filter/query updates without model calls. + +### Phase 2 — visual-material workbench + +- [ ] Add semantic chart/diagram/media archetypes in task-specific libraries. +- [ ] Add image/video rights, crop, focal-point, storyboard, motion, and PPTX fallback contracts. +- [ ] Add saved visual recipes with explicit deck/tenant scope and versioned schemas. +- [ ] Add candidate A/B comparison and source-figure versus native-reconstruction comparison. + +### Phase 3 — traces and QA + +- [ ] Render job progress, tool receipts, source/claim lineage, repair attempts, candidate validation, + and export results through OpenUI. +- [ ] Keep the current compact trace as the default; OpenUI expands it, not replaces provenance. +- [ ] Add density, hierarchy, clutter, citation, contrast, overflow, export, and deck-rhythm programs. +- [ ] Preserve OpenUI programs, errors, actions, tool calls, and render digests with bounded retention + and deletion controls. + +### Phase 4 — benchmark and promotion + +- [ ] Compare plain markdown, current hard-coded React cards, and OpenUI on identical deck tasks. +- [ ] Measure time to first useful visual, completion, token/tool counts, invalid-component rate, + repair count, human selection rate, proposal acceptance, and edit-after-accept rate. +- [ ] Freeze component/library/tool/prompt versions for every run. +- [ ] Promote only if safety gates hold and OpenUI improves a declared product outcome; a token-only + win is insufficient. + +## 8. Security, QA, and release bar + +### 8.1 OpenUI security invariants + +- [ ] Treat every generated OpenUI program and every source/claim string as untrusted data. +- [ ] Allowlist components and tools; reject unknown components/tools without broadening authority. +- [ ] Queries are read-only by default; mutations require explicit trigger and server-side approval. +- [ ] Never embed owner/provider credentials, arbitrary URLs, raw HTML, or executable JavaScript in + programs or component props. +- [ ] Apply deck/tenant/source authorization independently of model output. +- [ ] Bind action intent to artifact/material ID, base deck/slide/element clocks, exact scope, + candidate digest, and idempotency key. +- [ ] Limit auto-refresh, rows, bytes, statements, components, nested depth, actions, and repair + attempts; fail closed after two identical failures. +- [ ] Require reduced-motion behavior and explicit PowerPoint fallbacks for motion/video. +- [ ] Preserve source licensing and retention state through the material and slide handoff. + +### 8.2 Current verified repository gates + +- [x] `pnpm typecheck` passed on the current tree. +- [x] `pnpm test` passed: **75 files, 500 tests**. +- [x] `@parity/nodeslide-agent-client`, Eve experiment, and MCP package typechecks passed. +- [x] Targeted agent-client + MCP tests passed. +- [x] `git diff --check` passed. +- [ ] **NOT RUN:** live Eve runtime, live OpenUI renderer, browser/pixel journey, staging deck flow, + production live-model flow, or deployment. +- [ ] **WARNING:** package commands report Node 22 against Eve's required Node 24. + +### 8.3 Existing QA context that remains open + +- [ ] P1 live GLM edit reliability remains open in `.qa/memory/findings.jsonl`. +- [ ] P1 whole-deck deletion/export-my-data remains open. +- [ ] P2 provider-throw fallback coverage and the D-tier iterative-agent/durable-job baseline remain + open in QA memory. +- [ ] Two previously fixed P1s remain mandatory regressions for the next real UI pass: + capability-honest labeling and first-run landing. +- [ ] The historical 2026-07-13 Agentic UI Bar score is not a current score for this uncommitted tree; + do not reuse it as fresh proof. +- [ ] The QA profile claims a repo-local `.claude/skills/nodeslide-qa/` copy and journeys, but that + directory is absent in the current tree. Repair the profile or restore the repo-local anchor before + the next formal dogfood pass. + +### 8.4 Release exit criteria + +- [ ] Node 24 Eve runtime starts cleanly from a frozen lockfile. +- [ ] Phase-0 deterministic hero passes with artifact-backed program, pixels, traces, candidate, + review, and native PPTX proof. +- [ ] Disposable staging hero passes without secret exposure or pre-review mutation. +- [ ] Consent-off external egress is network-verified as zero. +- [ ] OpenUI program cannot call unregistered tools or arbitrary URLs. +- [ ] Selection does not mutate; accept/reject remain separate, exact, and version-bound. +- [ ] Trace identifies actual model/provider/transport, OpenUI artifact, tool calls, proposal, digest, + validation, human review, cost/tokens where applicable, and resulting version. +- [ ] Desktop/tablet/mobile × light/dark pixels show reachable actions, visible focus, no clipping, + correct reduced motion, and honest loading/error/repair states. +- [ ] The 10-case Eve/OpenUI dogfood evaluation passes or records explicit holds. +- [ ] No open P0/P1 introduced by the spike; all affected repository gates are green on the final + tree. + +## 9. Evidence limitations + +- [ ] **BLOCKED:** the pasted notes mention `nodeslide_live_agent_dogfood_one_pager_2026-07-16.pdf` + and `nodeslide_dogfood_interview_deck_2026-07-16.pptx`, but neither file was found in the repo or + attachment directory available to this task. Their exact requests, layouts, and evidence cannot be + mapped or credited until the actual files are attached. +- [x] The architecture/research text from both pasted attachments was mapped. +- [x] Current first-party OpenUI, npm, and Eve documentation was checked on 2026-07-15. +- [x] Current code, uncommitted diff, package metadata, tests, QA memory, and key contracts were + inspected. + +## 10. Recommended next action + +- [ ] Implement **only Phase 0** on a dedicated branch/worktree: + 1. contract + exact dependency pin; + 2. six-component custom NodeSlide library; + 3. server-side read-only tool provider; + 4. AI 2027 mixed-unit candidate comparison; + 5. `VisualMaterialSpec` → SlideLang proposal; + 6. deterministic proof and native PPTX inspection; + 7. disposable staging review flow; + 8. only then extend the Eve visual-review skill/subagent. + +This order proves the unique product value—the visible, source-aware visual decision between research +and a reviewable slide—before adding channels, schedules, a broad component catalog, or a second +runtime surface. diff --git a/artifacts/nodeslide-openui-live-agent-proof-2026-07-16.pptx b/artifacts/nodeslide-openui-live-agent-proof-2026-07-16.pptx new file mode 100644 index 0000000..4c8042a Binary files /dev/null and b/artifacts/nodeslide-openui-live-agent-proof-2026-07-16.pptx differ diff --git a/artifacts/nodeslide-openui-live-agent-proof-2026-07-16.pptx.inspect.ndjson b/artifacts/nodeslide-openui-live-agent-proof-2026-07-16.pptx.inspect.ndjson new file mode 100644 index 0000000..ca59965 --- /dev/null +++ b/artifacts/nodeslide-openui-live-agent-proof-2026-07-16.pptx.inspect.ndjson @@ -0,0 +1,103 @@ +{"kind":"deck","id":"pr/d2r15p","name":"Deck"} +{"kind":"layout","layoutId":"76b35871-2f82-400d-a95a-504c9bf0a8cd","name":"Master","type":"master"} +{"kind":"layout","layoutId":"4131ffe9-70f1-45b4-aa2b-16d898fa1bd8","name":"Title Slide","type":"title"} +{"kind":"slide","id":"sl/h8vvsa","slide":1,"title":"NODESLIDE / OPENUI PHASE 0","textShapes":6} +{"kind":"textbox","id":"sh/qhwveh8b","slide":1,"name":"cover-eyebrow","text":"NODESLIDE / OPENUI PHASE 0","textPreview":"NODESLIDE / OPENUI PHASE 0","textChars":26,"textLines":1,"bbox":[42,40,620,34]} +{"kind":"textbox","id":"sh/dk7epwrm","slide":1,"name":"cover-title","text":"OpenUI makes the visual\ndecision visible before\nthe slide changes","textPreview":"OpenUI makes the visual | decision visible before | the slide changes","textChars":65,"textLines":3,"bbox":[42,165,1010,305]} +{"kind":"textbox","id":"sh/cjedgrq1","slide":1,"name":"cover-subtitle","text":"A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.","textPreview":"A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.","textChars":104,"textLines":1,"bbox":[42,520,840,92]} +{"kind":"shape","id":"sh/3e5wjm9k","slide":1,"bbox":[1075,40,163,20]} +{"kind":"textbox","id":"sh/idwvah8z","slide":1,"text":"LIVE LOCAL RUN","textPreview":"LIVE LOCAL RUN","textChars":14,"textLines":1,"bbox":[1062,72,176,24]} +{"kind":"textbox","id":"sh/pgnelwrq","slide":1,"name":"footer-label-1","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/4fedcrq5","slide":1,"name":"footer-page-1","text":"01","textPreview":"01","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"notes","id":"nt/h8vvsa","slide":1,"text":""} +{"kind":"slide","id":"sl/eye12a","slide":2,"title":"Four incompatible units need a transformation ladder—not one axis","textShapes":25} +{"kind":"textbox","id":"sh/don6t4je","slide":2,"name":"slide-title-2","text":"Four incompatible units need a transformation ladder—not one axis","textPreview":"Four incompatible units need a transformation ladder—not one axis","textChars":65,"textLines":1,"bbox":[42,36,1196,92]} +{"kind":"textbox","id":"sh/qlcnipkn","slide":2,"name":"footer-label-2","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/rmloru18","slide":2,"name":"footer-page-2","text":"02","textPreview":"02","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"textbox","id":"sh/4ju5gf2x","slide":2,"text":"The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.","textPreview":"The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.","textChars":106,"textLines":1,"bbox":[42,125,1000,60]} +{"kind":"shape","id":"sh/pk3mpkj2","slide":2,"bbox":[42,247,262,285]} +{"kind":"textbox","id":"sh/2hcne5kr","slide":2,"text":"01","textPreview":"01","textChars":2,"textLines":1,"bbox":[62,268,40,24]} +{"kind":"textbox","id":"sh/3il4na1c","slide":2,"text":"REPLICATION","textPreview":"REPLICATION","textChars":11,"textLines":1,"bbox":[100,268,182,38]} +{"kind":"textbox","id":"sh/ofy5sj21","slide":2,"text":"200K copies","textPreview":"200K copies","textChars":11,"textLines":1,"bbox":[62,343,218,88]} +{"kind":"shape","id":"sh/9g761ojm","slide":2,"bbox":[62,451,218,0]} +{"kind":"textbox","id":"sh/lkn2d0bq","slide":2,"text":"copies","textPreview":"copies","textChars":6,"textLines":1,"bbox":[62,469,218,42]} +{"kind":"textbox","id":"sh/kjelkfa5","slide":2,"text":"→","textPreview":"→","textChars":1,"textLines":1,"bbox":[304,365,24,40]} +{"kind":"shape","id":"sh/zi5kbatk","slide":2,"bbox":[328,247,262,285]} +{"kind":"textbox","id":"sh/yhw3i5sz","slide":2,"text":"02","textPreview":"02","textChars":2,"textLines":1,"bbox":[348,268,40,24]} +{"kind":"textbox","id":"sh/xg3290be","slide":2,"text":"EQUIVALENT CAPACITY","textPreview":"EQUIVALENT CAPACITY","textChars":19,"textLines":1,"bbox":[386,268,182,38]} +{"kind":"textbox","id":"sh/cfulgvat","slide":2,"text":"50K coder\nequivalents","textPreview":"50K coder | equivalents","textChars":21,"textLines":2,"bbox":[348,343,218,88]} +{"kind":"shape","id":"sh/belk7qt8","slide":2,"bbox":[348,451,218,0]} +{"kind":"textbox","id":"sh/adc3els3","slide":2,"text":"coder equivalents","textPreview":"coder equivalents","textChars":17,"textLines":1,"bbox":[348,469,218,42]} +{"kind":"textbox","id":"sh/hc7mlkry","slide":2,"text":"→","textPreview":"→","textChars":1,"textLines":1,"bbox":[590,365,24,40]} +{"kind":"shape","id":"sh/wbylszad","slide":2,"bbox":[614,247,262,285]} +{"kind":"textbox","id":"sh/gf65o3i9","slide":2,"text":"03","textPreview":"03","textChars":2,"textLines":1,"bbox":[634,268,40,24]} +{"kind":"textbox","id":"sh/1gf6h8zu","slide":2,"text":"INDIVIDUAL ACCELERATION","textPreview":"INDIVIDUAL ACCELERATION","textChars":23,"textLines":1,"bbox":[672,268,182,38]} +{"kind":"textbox","id":"sh/etonmt0j","slide":2,"text":"30× speed","textPreview":"30× speed","textChars":9,"textLines":1,"bbox":[634,343,218,88]} +{"kind":"shape","id":"sh/fux4fyho","slide":2,"bbox":[634,451,218,0]} +{"kind":"textbox","id":"sh/srm5kjid","slide":2,"text":"individual speed multiplier","textPreview":"individual speed multiplier","textChars":27,"textLines":1,"bbox":[634,469,218,42]} +{"kind":"textbox","id":"sh/tsfmdozy","slide":2,"text":"→","textPreview":"→","textChars":1,"textLines":1,"bbox":[876,365,24,40]} +{"kind":"shape","id":"sh/6p4nit07","slide":2,"bbox":[900,247,262,285]} +{"kind":"textbox","id":"sh/rqd4behs","slide":2,"text":"04","textPreview":"04","textChars":2,"textLines":1,"bbox":[920,268,40,24]} +{"kind":"textbox","id":"sh/knm5gji1","slide":2,"text":"SYSTEM OUTCOME","textPreview":"SYSTEM OUTCOME","textChars":14,"textLines":1,"bbox":[958,268,182,38]} +{"kind":"textbox","id":"sh/5ovm9ozm","slide":2,"text":"4× research\nprogress","textPreview":"4× research | progress","textChars":20,"textLines":2,"bbox":[920,343,218,88]} +{"kind":"shape","id":"sh/nitozelo","slide":2,"bbox":[920,451,218,0]} +{"kind":"textbox","id":"sh/mhknq9k3","slide":2,"text":"overall progress multiplier","textPreview":"overall progress multiplier","textChars":27,"textLines":1,"bbox":[920,469,218,42]} +{"kind":"textbox","id":"sh/9kb61o3u","slide":2,"text":"USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED","textPreview":"USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED","textChars":43,"textLines":1,"bbox":[42,574,560,30]} +{"kind":"textbox","id":"sh/8j25sj29","slide":2,"text":"NO SHARED AXIS · MIXED UNITS","textPreview":"NO SHARED AXIS · MIXED UNITS","textChars":28,"textLines":1,"bbox":[830,574,408,30]} +{"kind":"notes","id":"nt/eye12a","slide":2,"text":""} +{"kind":"slide","id":"sl/251tu4","slide":3,"title":"Selection crosses one governed boundary at a time","textShapes":17} +{"kind":"textbox","id":"sh/dwz29036","slide":3,"name":"slide-title-3","text":"Selection crosses one governed boundary at a time","textPreview":"Selection crosses one governed boundary at a time","textChars":49,"textLines":1,"bbox":[42,36,1196,92]} +{"kind":"textbox","id":"sh/qto3y5kf","slide":3,"name":"footer-label-3","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/ruxk7ql0","slide":3,"name":"footer-page-3","text":"03","textPreview":"03","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"textbox","id":"sh/0zql4f2h","slide":3,"text":"OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.","textPreview":"OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.","textChars":94,"textLines":1,"bbox":[42,126,1020,55]} +{"kind":"shape","id":"sh/10zmdk32","slide":3,"bbox":[78,355,1092,0]} +{"kind":"shape","id":"sh/ex8325kr","slide":3,"bbox":[78,346,18,18]} +{"kind":"textbox","id":"sh/zyh4balc","slide":3,"text":"01","textPreview":"01","textChars":2,"textLines":1,"bbox":[78,294,44,28]} +{"kind":"textbox","id":"sh/onal8f2t","slide":3,"text":"OPENUI LANG","textPreview":"OPENUI LANG","textChars":11,"textLines":1,"bbox":[78,390,245,34]} +{"kind":"textbox","id":"sh/9ojmhkje","slide":3,"text":"Render an allowlisted\nvisual decision","textPreview":"Render an allowlisted | visual decision","textChars":37,"textLines":2,"bbox":[78,436,250,84]} +{"kind":"shape","id":"sh/5g3etgne","slide":3,"bbox":[370,346,18,18]} +{"kind":"textbox","id":"sh/4fux0b6t","slide":3,"text":"02","textPreview":"02","textChars":2,"textLines":1,"bbox":[370,294,44,28]} +{"kind":"textbox","id":"sh/3elwr658","slide":3,"text":"VISUAL MATERIAL SPEC","textPreview":"VISUAL MATERIAL SPEC","textChars":20,"textLines":1,"bbox":[370,390,245,34]} +{"kind":"textbox","id":"sh/itcfy1on","slide":3,"text":"Validate units, labels,\nand provenance","textPreview":"Validate units, labels, | and provenance","textChars":38,"textLines":2,"bbox":[370,436,250,84]} +{"kind":"shape","id":"sh/dknexgna","slide":3,"bbox":[662,346,18,18]} +{"kind":"textbox","id":"sh/sjex4b6p","slide":3,"text":"03","textPreview":"03","textChars":2,"textLines":1,"bbox":[662,294,44,28]} +{"kind":"textbox","id":"sh/rilwvq5k","slide":3,"text":"SLIDELANG PROPOSAL","textPreview":"SLIDELANG PROPOSAL","textChars":18,"textLines":1,"bbox":[662,390,245,34]} +{"kind":"textbox","id":"sh/qhcf2loz","slide":3,"text":"Compile one editable\nadd-slide operation","textPreview":"Compile one editable | add-slide operation","textChars":40,"textLines":2,"bbox":[662,436,250,84]} +{"kind":"shape","id":"sh/toje1wn6","slide":3,"bbox":[954,346,18,18]} +{"kind":"textbox","id":"sh/8nadsr6l","slide":3,"text":"04","textPreview":"04","textChars":2,"textLines":1,"bbox":[954,294,44,28]} +{"kind":"textbox","id":"sh/kjy54vit","slide":3,"text":"HUMAN ACCEPT","textPreview":"HUMAN ACCEPT","textChars":12,"textLines":1,"bbox":[954,390,245,34]} +{"kind":"textbox","id":"sh/lk76xgze","slide":3,"text":"CAS + candidate receipt\nadvance v1 → v2","textPreview":"CAS + candidate receipt | advance v1 → v2","textChars":39,"textLines":2,"bbox":[954,436,250,84]} +{"kind":"shape","id":"sh/yhwn2l0n","slide":3,"bbox":[42,575,1196,55]} +{"kind":"textbox","id":"sh/ji5ovqh8","slide":3,"text":"Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id","textPreview":"Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id","textChars":91,"textLines":1,"bbox":[65,592,1150,28]} +{"kind":"notes","id":"nt/251tu4","slide":3,"text":""} +{"kind":"slide","id":"sl/rbc51e","slide":4,"title":"The live interaction proved the boundary—not just the render","textShapes":10} +{"kind":"textbox","id":"sh/8z6p87al","slide":4,"name":"slide-title-4","text":"The live interaction proved the boundary—not just the render","textPreview":"The live interaction proved the boundary—not just the render","textChars":60,"textLines":1,"bbox":[42,36,1196,92]} +{"kind":"textbox","id":"sh/nyxozm90","slide":4,"name":"footer-label-4","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/mxo76hsf","slide":4,"name":"footer-page-4","text":"04","textPreview":"04","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"textbox","id":"sh/lwv6xcru","slide":4,"text":"Disposable local workspace · exact candidate preview corrected during dogfood","textPreview":"Disposable local workspace · exact candidate preview corrected during dogfood","textChars":77,"textLines":1,"bbox":[42,126,790,45]} +{"kind":"shape","id":"sh/zadov29o","slide":4,"bbox":[858,194,380,128]} +{"kind":"textbox","id":"sh/e94n2xs3","slide":4,"text":"15 / 15","textPreview":"15 / 15","textChars":7,"textLines":1,"bbox":[884,216,330,52]} +{"kind":"textbox","id":"sh/5sz69wry","slide":4,"text":"OpenUI + agent contract tests passed","textPreview":"OpenUI + agent contract tests passed","textChars":36,"textLines":1,"bbox":[884,274,330,36]} +{"kind":"shape","id":"sh/krqpgbat","slide":4,"bbox":[858,342,380,128]} +{"kind":"textbox","id":"sh/ahgr2dg7","slide":4,"text":"v1 → v2","textPreview":"v1 → v2","textChars":7,"textLines":1,"bbox":[884,364,330,52]} +{"kind":"textbox","id":"sh/bip8bixs","slide":4,"text":"Pending proposal stayed unapplied until Accept","textPreview":"Pending proposal stayed unapplied until Accept","textChars":46,"textLines":1,"bbox":[884,422,330,40]} +{"kind":"shape","id":"sh/wjy94nyd","slide":4,"bbox":[858,490,380,142]} +{"kind":"textbox","id":"sh/xk7qdsfy","slide":4,"text":"1 operation","textPreview":"1 operation","textChars":11,"textLines":1,"bbox":[884,512,330,46]} +{"kind":"textbox","id":"sh/mdwrydgb","slide":4,"text":"Editable add-slide patch\nwith a candidate validation receipt","textPreview":"Editable add-slide patch | with a candidate validation receipt","textChars":60,"textLines":2,"bbox":[884,566,330,58]} +{"kind":"image","id":"im/1kneh43i","slide":4,"name":"","alt":"NodeSlide Compare showing the baseline and the new OpenUI visual material proposal.","bbox":[42,194,780,438]} +{"kind":"notes","id":"nt/rbc51e","slide":4,"text":""} +{"kind":"slide","id":"sl/050391","slide":5,"title":"RELEASE BAR","textShapes":10} +{"kind":"textbox","id":"sh/yl0rylg3","slide":5,"text":"RELEASE BAR","textPreview":"RELEASE BAR","textChars":11,"textLines":1,"bbox":[42,40,260,30]} +{"kind":"textbox","id":"sh/l8rq90fu","slide":5,"text":"Adopt Phase 0 now.\nKeep the boundary.","textPreview":"Adopt Phase 0 now. | Keep the boundary.","textChars":37,"textLines":2,"bbox":[42,178,950,245]} +{"kind":"shape","id":"sh/kni90vy9","slide":5,"bbox":[42,492,350,0]} +{"kind":"textbox","id":"sh/7a98bax0","slide":5,"text":"01 OPENUI","textPreview":"01 OPENUI","textChars":10,"textLines":1,"bbox":[42,516,350,32]} +{"kind":"textbox","id":"sh/m90r25gf","slide":5,"text":"Bounded component library + visible provenance","textPreview":"Bounded component library + visible provenance","textChars":46,"textLines":1,"bbox":[42,558,350,70]} +{"kind":"shape","id":"sh/9crad0fq","slide":5,"bbox":[437,492,350,0]} +{"kind":"textbox","id":"sh/8bi94fyl","slide":5,"text":"02 SLIDELANG","textPreview":"02 SLIDELANG","textChars":13,"textLines":1,"bbox":[437,516,350,32]} +{"kind":"textbox","id":"sh/nep8z6xg","slide":5,"text":"Editable output + exact candidate validation","textPreview":"Editable output + exact candidate validation","textChars":44,"textLines":1,"bbox":[437,558,350,70]} +{"kind":"shape","id":"sh/2dwrq1gv","slide":5,"bbox":[832,492,350,0]} +{"kind":"textbox","id":"sh/wrypgv6l","slide":5,"text":"03 HUMAN REVIEW","textPreview":"03 HUMAN REVIEW","textChars":16,"textLines":1,"bbox":[832,516,350,32]} +{"kind":"textbox","id":"sh/xs7qp0nq","slide":5,"text":"No mutation before explicit Accept","textPreview":"No mutation before explicit Accept","textChars":34,"textLines":1,"bbox":[832,558,350,70]} +{"kind":"textbox","id":"sh/apg7elof","slide":5,"name":"footer-label-5","text":"NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI","textPreview":"NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI","textChars":47,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/vqp8nq50","slide":5,"name":"footer-page-5","text":"05","textPreview":"05","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"notes","id":"nt/050391","slide":5,"text":""} \ No newline at end of file diff --git a/artifacts/nodeslide-polish-2026-07-16/desktop-inspector-effort.png b/artifacts/nodeslide-polish-2026-07-16/desktop-inspector-effort.png new file mode 100644 index 0000000..4ab96cb Binary files /dev/null and b/artifacts/nodeslide-polish-2026-07-16/desktop-inspector-effort.png differ diff --git a/artifacts/nodeslide-polish-2026-07-16/mobile-short-thread.png b/artifacts/nodeslide-polish-2026-07-16/mobile-short-thread.png new file mode 100644 index 0000000..f97d2ac Binary files /dev/null and b/artifacts/nodeslide-polish-2026-07-16/mobile-short-thread.png differ diff --git a/artifacts/nodeslide-refresh-control-2026-07-16/production-before-landing.png b/artifacts/nodeslide-refresh-control-2026-07-16/production-before-landing.png new file mode 100644 index 0000000..b52ea11 Binary files /dev/null and b/artifacts/nodeslide-refresh-control-2026-07-16/production-before-landing.png differ diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b986392..2801883 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -30,21 +30,29 @@ import type * as lib_nodeslideAgenticControls from "../lib/nodeslideAgenticContr import type * as lib_nodeslideAgenticTelemetry from "../lib/nodeslideAgenticTelemetry.js"; import type * as lib_nodeslideAnalysisKernel from "../lib/nodeslideAnalysisKernel.js"; import type * as lib_nodeslideAuthority from "../lib/nodeslideAuthority.js"; +import type * as lib_nodeslideBudgetLedger from "../lib/nodeslideBudgetLedger.js"; +import type * as lib_nodeslideBudgetedProvider from "../lib/nodeslideBudgetedProvider.js"; import type * as lib_nodeslideCandidate from "../lib/nodeslideCandidate.js"; +import type * as lib_nodeslideClaimEvidenceReceipt from "../lib/nodeslideClaimEvidenceReceipt.js"; import type * as lib_nodeslideCreationTelemetry from "../lib/nodeslideCreationTelemetry.js"; import type * as lib_nodeslideData from "../lib/nodeslideData.js"; import type * as lib_nodeslideDataAttachment from "../lib/nodeslideDataAttachment.js"; import type * as lib_nodeslideDataExport from "../lib/nodeslideDataExport.js"; +import type * as lib_nodeslideDeckCi from "../lib/nodeslideDeckCi.js"; import type * as lib_nodeslideDeckDeletion from "../lib/nodeslideDeckDeletion.js"; import type * as lib_nodeslideDeckRepl from "../lib/nodeslideDeckRepl.js"; +import type * as lib_nodeslideDurableSessionState from "../lib/nodeslideDurableSessionState.js"; import type * as lib_nodeslideEditPlanner from "../lib/nodeslideEditPlanner.js"; import type * as lib_nodeslideEditShadowPlanner from "../lib/nodeslideEditShadowPlanner.js"; +import type * as lib_nodeslideEvidenceCapture from "../lib/nodeslideEvidenceCapture.js"; import type * as lib_nodeslideExecutionTrace from "../lib/nodeslideExecutionTrace.js"; import type * as lib_nodeslideExecutionTraceValidator from "../lib/nodeslideExecutionTraceValidator.js"; import type * as lib_nodeslideGoogleOAuth from "../lib/nodeslideGoogleOAuth.js"; import type * as lib_nodeslideIds from "../lib/nodeslideIds.js"; +import type * as lib_nodeslideJobJournal from "../lib/nodeslideJobJournal.js"; import type * as lib_nodeslideJobState from "../lib/nodeslideJobState.js"; import type * as lib_nodeslideJobValidators from "../lib/nodeslideJobValidators.js"; +import type * as lib_nodeslideLiveDeckRepl from "../lib/nodeslideLiveDeckRepl.js"; import type * as lib_nodeslideManagedKernel from "../lib/nodeslideManagedKernel.js"; import type * as lib_nodeslideMemoryPolicy from "../lib/nodeslideMemoryPolicy.js"; import type * as lib_nodeslideOtlp from "../lib/nodeslideOtlp.js"; @@ -58,14 +66,18 @@ import type * as lib_nodeslideQuota from "../lib/nodeslideQuota.js"; import type * as lib_nodeslideReadContext from "../lib/nodeslideReadContext.js"; import type * as lib_nodeslideRenderRepairLoop from "../lib/nodeslideRenderRepairLoop.js"; import type * as lib_nodeslideRoutingPolicy from "../lib/nodeslideRoutingPolicy.js"; +import type * as lib_nodeslideRunBudget from "../lib/nodeslideRunBudget.js"; import type * as lib_nodeslideSeed from "../lib/nodeslideSeed.js"; import type * as lib_nodeslideSemanticEvaluation from "../lib/nodeslideSemanticEvaluation.js"; import type * as lib_nodeslideShadowComparison from "../lib/nodeslideShadowComparison.js"; import type * as lib_nodeslideShadowComparisonValidator from "../lib/nodeslideShadowComparisonValidator.js"; import type * as lib_nodeslideSignatureProfiles from "../lib/nodeslideSignatureProfiles.js"; import type * as lib_nodeslideSourceLineage from "../lib/nodeslideSourceLineage.js"; +import type * as lib_nodeslideSourceRefresh from "../lib/nodeslideSourceRefresh.js"; +import type * as lib_nodeslideSourceRevision from "../lib/nodeslideSourceRevision.js"; import type * as lib_nodeslideStoryBench from "../lib/nodeslideStoryBench.js"; import type * as lib_nodeslideTasteMismatch from "../lib/nodeslideTasteMismatch.js"; +import type * as lib_nodeslideUploadPolicy from "../lib/nodeslideUploadPolicy.js"; import type * as lib_nodeslideValidation from "../lib/nodeslideValidation.js"; import type * as lib_nodeslideValidators from "../lib/nodeslideValidators.js"; import type * as lib_nodeslideVariationHarness from "../lib/nodeslideVariationHarness.js"; @@ -79,17 +91,23 @@ import type * as lib_staticLint from "../lib/staticLint.js"; import type * as lib_uiKitParser from "../lib/uiKitParser.js"; import type * as nodeslide from "../nodeslide.js"; import type * as nodeslideAgent from "../nodeslideAgent.js"; +import type * as nodeslideAuthoringQuality from "../nodeslideAuthoringQuality.js"; +import type * as nodeslideBudgets from "../nodeslideBudgets.js"; import type * as nodeslideDataExport from "../nodeslideDataExport.js"; +import type * as nodeslideDeckCi from "../nodeslideDeckCi.js"; import type * as nodeslideDelegation from "../nodeslideDelegation.js"; import type * as nodeslideGoogleAuth from "../nodeslideGoogleAuth.js"; +import type * as nodeslideJobControl from "../nodeslideJobControl.js"; import type * as nodeslideJobRunner from "../nodeslideJobRunner.js"; import type * as nodeslideJobWorkflow from "../nodeslideJobWorkflow.js"; import type * as nodeslideJobs from "../nodeslideJobs.js"; import type * as nodeslideMemory from "../nodeslideMemory.js"; import type * as nodeslidePreferences from "../nodeslidePreferences.js"; +import type * as nodeslideSessions from "../nodeslideSessions.js"; import type * as nodeslideSignatures from "../nodeslideSignatures.js"; import type * as nodeslideSync from "../nodeslideSync.js"; import type * as nodeslideTelemetry from "../nodeslideTelemetry.js"; +import type * as nodeslideUploads from "../nodeslideUploads.js"; import type * as nodeslideVariationProof from "../nodeslideVariationProof.js"; import type * as nodeslideVariationProvider from "../nodeslideVariationProvider.js"; import type * as nodeslideVariations from "../nodeslideVariations.js"; @@ -128,21 +146,29 @@ declare const fullApi: ApiFromModules<{ "lib/nodeslideAgenticTelemetry": typeof lib_nodeslideAgenticTelemetry; "lib/nodeslideAnalysisKernel": typeof lib_nodeslideAnalysisKernel; "lib/nodeslideAuthority": typeof lib_nodeslideAuthority; + "lib/nodeslideBudgetLedger": typeof lib_nodeslideBudgetLedger; + "lib/nodeslideBudgetedProvider": typeof lib_nodeslideBudgetedProvider; "lib/nodeslideCandidate": typeof lib_nodeslideCandidate; + "lib/nodeslideClaimEvidenceReceipt": typeof lib_nodeslideClaimEvidenceReceipt; "lib/nodeslideCreationTelemetry": typeof lib_nodeslideCreationTelemetry; "lib/nodeslideData": typeof lib_nodeslideData; "lib/nodeslideDataAttachment": typeof lib_nodeslideDataAttachment; "lib/nodeslideDataExport": typeof lib_nodeslideDataExport; + "lib/nodeslideDeckCi": typeof lib_nodeslideDeckCi; "lib/nodeslideDeckDeletion": typeof lib_nodeslideDeckDeletion; "lib/nodeslideDeckRepl": typeof lib_nodeslideDeckRepl; + "lib/nodeslideDurableSessionState": typeof lib_nodeslideDurableSessionState; "lib/nodeslideEditPlanner": typeof lib_nodeslideEditPlanner; "lib/nodeslideEditShadowPlanner": typeof lib_nodeslideEditShadowPlanner; + "lib/nodeslideEvidenceCapture": typeof lib_nodeslideEvidenceCapture; "lib/nodeslideExecutionTrace": typeof lib_nodeslideExecutionTrace; "lib/nodeslideExecutionTraceValidator": typeof lib_nodeslideExecutionTraceValidator; "lib/nodeslideGoogleOAuth": typeof lib_nodeslideGoogleOAuth; "lib/nodeslideIds": typeof lib_nodeslideIds; + "lib/nodeslideJobJournal": typeof lib_nodeslideJobJournal; "lib/nodeslideJobState": typeof lib_nodeslideJobState; "lib/nodeslideJobValidators": typeof lib_nodeslideJobValidators; + "lib/nodeslideLiveDeckRepl": typeof lib_nodeslideLiveDeckRepl; "lib/nodeslideManagedKernel": typeof lib_nodeslideManagedKernel; "lib/nodeslideMemoryPolicy": typeof lib_nodeslideMemoryPolicy; "lib/nodeslideOtlp": typeof lib_nodeslideOtlp; @@ -156,14 +182,18 @@ declare const fullApi: ApiFromModules<{ "lib/nodeslideReadContext": typeof lib_nodeslideReadContext; "lib/nodeslideRenderRepairLoop": typeof lib_nodeslideRenderRepairLoop; "lib/nodeslideRoutingPolicy": typeof lib_nodeslideRoutingPolicy; + "lib/nodeslideRunBudget": typeof lib_nodeslideRunBudget; "lib/nodeslideSeed": typeof lib_nodeslideSeed; "lib/nodeslideSemanticEvaluation": typeof lib_nodeslideSemanticEvaluation; "lib/nodeslideShadowComparison": typeof lib_nodeslideShadowComparison; "lib/nodeslideShadowComparisonValidator": typeof lib_nodeslideShadowComparisonValidator; "lib/nodeslideSignatureProfiles": typeof lib_nodeslideSignatureProfiles; "lib/nodeslideSourceLineage": typeof lib_nodeslideSourceLineage; + "lib/nodeslideSourceRefresh": typeof lib_nodeslideSourceRefresh; + "lib/nodeslideSourceRevision": typeof lib_nodeslideSourceRevision; "lib/nodeslideStoryBench": typeof lib_nodeslideStoryBench; "lib/nodeslideTasteMismatch": typeof lib_nodeslideTasteMismatch; + "lib/nodeslideUploadPolicy": typeof lib_nodeslideUploadPolicy; "lib/nodeslideValidation": typeof lib_nodeslideValidation; "lib/nodeslideValidators": typeof lib_nodeslideValidators; "lib/nodeslideVariationHarness": typeof lib_nodeslideVariationHarness; @@ -177,17 +207,23 @@ declare const fullApi: ApiFromModules<{ "lib/uiKitParser": typeof lib_uiKitParser; nodeslide: typeof nodeslide; nodeslideAgent: typeof nodeslideAgent; + nodeslideAuthoringQuality: typeof nodeslideAuthoringQuality; + nodeslideBudgets: typeof nodeslideBudgets; nodeslideDataExport: typeof nodeslideDataExport; + nodeslideDeckCi: typeof nodeslideDeckCi; nodeslideDelegation: typeof nodeslideDelegation; nodeslideGoogleAuth: typeof nodeslideGoogleAuth; + nodeslideJobControl: typeof nodeslideJobControl; nodeslideJobRunner: typeof nodeslideJobRunner; nodeslideJobWorkflow: typeof nodeslideJobWorkflow; nodeslideJobs: typeof nodeslideJobs; nodeslideMemory: typeof nodeslideMemory; nodeslidePreferences: typeof nodeslidePreferences; + nodeslideSessions: typeof nodeslideSessions; nodeslideSignatures: typeof nodeslideSignatures; nodeslideSync: typeof nodeslideSync; nodeslideTelemetry: typeof nodeslideTelemetry; + nodeslideUploads: typeof nodeslideUploads; nodeslideVariationProof: typeof nodeslideVariationProof; nodeslideVariationProvider: typeof nodeslideVariationProvider; nodeslideVariations: typeof nodeslideVariations; diff --git a/convex/crons.ts b/convex/crons.ts index 0973e00..be53ec9 100644 --- a/convex/crons.ts +++ b/convex/crons.ts @@ -24,4 +24,11 @@ crons.interval( {}, ); +crons.interval( + 'prune expired NodeSlide visual evidence', + { hours: 1 }, + internal.nodeslide.pruneExpiredEvidenceCapturesInternal, + {}, +); + export default crons; diff --git a/convex/inspirationSearch.ts b/convex/inspirationSearch.ts index 4564aaa..38ea286 100644 --- a/convex/inspirationSearch.ts +++ b/convex/inspirationSearch.ts @@ -36,6 +36,8 @@ interface LinkupResponseSource { } const MAX_SEARCH_RESPONSE_BYTES = 200_000; +const LINKUP_SEARCH_TIMEOUT_MS = 15_000; +const LINKUP_SEARCH_ATTEMPTS = 2; function env(name: string): string { return process.env[name]?.trim() ?? ''; @@ -126,36 +128,38 @@ async function readBoundedJson(response: Response): Promise { async function searchLinkup(query: string): Promise { const key = env('LINKUP_API_KEY'); if (!key) return []; - const result = await withTimeout(9000, async (signal) => { - const response = await fetch('https://api.linkup.so/v1/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, - body: JSON.stringify({ - q: query, - depth: 'standard', - outputType: 'sourcedAnswer', - includeInlineCitations: true, - includeSources: true, - maxResults: 8, - }), - signal, - }); - if (!response.ok) return []; - const data = await readBoundedJson<{ - answer?: string; - results?: LinkupResponseSource[]; - sources?: LinkupResponseSource[]; - }>(response); - if (!data) return []; - return (data.results ?? data.sources ?? []).map((item) => ({ - title: item.name ?? item.title ?? item.url ?? 'Live web result', - url: item.url ?? '', - snippet: item.content ?? item.snippet ?? data.answer?.slice(0, 1000) ?? '', - provider: 'linkup', - mediaType: 'website' as const, - })); - }); - return result ?? []; + for (let attempt = 0; attempt < LINKUP_SEARCH_ATTEMPTS; attempt += 1) { + const result = await withTimeout( + LINKUP_SEARCH_TIMEOUT_MS, + async (signal) => { + const response = await fetch('https://api.linkup.so/v1/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` }, + body: JSON.stringify({ + q: query, + depth: 'standard', + // NodeSlide reasons over and cites the sources itself. Asking Linkup for another + // synthesized answer adds latency and a second generation step without adding lineage. + outputType: 'searchResults', + maxResults: 8, + }), + signal, + }); + if (!response.ok) return response.status === 429 || response.status >= 500 ? null : []; + const data = await readBoundedJson<{ results?: LinkupResponseSource[] }>(response); + if (!data) return null; + return (data.results ?? []).map((item) => ({ + title: item.name ?? item.title ?? item.url ?? 'Live web result', + url: item.url ?? '', + snippet: item.content ?? item.snippet ?? '', + provider: 'linkup', + mediaType: 'website' as const, + })); + }, + ); + if (result !== null) return result; + } + return []; } async function searchBraveWeb(query: string): Promise { diff --git a/convex/lib/inspirationSearch.test.ts b/convex/lib/inspirationSearch.test.ts index 778c262..e6f78dd 100644 --- a/convex/lib/inspirationSearch.test.ts +++ b/convex/lib/inspirationSearch.test.ts @@ -7,7 +7,7 @@ afterEach(() => { }); describe('NodeSlide live search adapter', () => { - it('parses Linkup sourcedAnswer sources with bounded excerpts', async () => { + it('parses Linkup searchResults sources with bounded excerpts', async () => { vi.stubEnv('LINKUP_API_KEY', 'test-linkup-key'); vi.stubEnv('BRAVE_SEARCH_API_KEY', ''); vi.stubEnv('BRAVE_API_KEY', ''); @@ -17,8 +17,7 @@ describe('NodeSlide live search adapter', () => { Promise.resolve( new Response( JSON.stringify({ - answer: 'The tournament expands to 48 teams across 12 groups.', - sources: [ + results: [ { name: 'Official tournament format', url: 'https://example.com/world-cup-format', @@ -46,9 +45,50 @@ describe('NodeSlide live search adapter', () => { const request = fetchMock.mock.calls[0]?.[1]; expect(request).toBeDefined(); expect(JSON.parse(String(request?.body))).toMatchObject({ - outputType: 'sourcedAnswer', - includeSources: true, + outputType: 'searchResults', + depth: 'standard', maxResults: 8, }); + expect(JSON.parse(String(request?.body))).not.toHaveProperty('includeSources'); + }); + + it('retries one transient provider failure without duplicating a successful request', async () => { + vi.stubEnv('LINKUP_API_KEY', 'test-linkup-key'); + vi.stubEnv('BRAVE_SEARCH_API_KEY', ''); + vi.stubEnv('BRAVE_API_KEY', ''); + vi.stubEnv('SERPER_API_KEY', ''); + vi.stubEnv('TAVILY_API_KEY', ''); + const fetchMock = vi + .fn() + .mockResolvedValueOnce(new Response(null, { status: 503 })) + .mockResolvedValueOnce( + jsonResponse({ + results: [ + { + name: 'Recovered source', + url: 'https://example.com/recovered', + content: 'Grounded after one bounded retry.', + }, + ], + }), + ); + vi.stubGlobal('fetch', fetchMock); + + const result = await searchExternalReferences('bounded retry', 'auto'); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(result.references).toEqual([ + expect.objectContaining({ + title: 'Recovered source', + sourceUrl: 'https://example.com/recovered', + }), + ]); }); }); + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} diff --git a/convex/lib/nodeslideAgentEvidenceCapture.test.ts b/convex/lib/nodeslideAgentEvidenceCapture.test.ts new file mode 100644 index 0000000..1d76e55 --- /dev/null +++ b/convex/lib/nodeslideAgentEvidenceCapture.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ActionCtx } from '../_generated/server'; +import { + captureWebSourcesBestEffort, + nodeSlideEvidenceAttachmentDigest, + pairNodeSlideStoredWebSources, +} from '../nodeslideAgent'; +import { nodeslideContentDigest, nodeslideStableId } from './nodeslideIds'; + +const DECK_ID = 'deck_evidence_hardening'; +const STORAGE_ID = 'storage_evidence_pdf'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('NodeSlide agent evidence capture hardening', () => { + it('persists the digest of the exact stored attachment bytes and distinguishes tampering', async () => { + vi.stubEnv('FIRECRAWL_API_KEY', ''); + const storedBlobs: Blob[] = []; + const runMutation = vi.fn(async () => ({ created: true })); + const context = actionContext({ + store: vi.fn(async (blob: Blob) => { + storedBlobs.push(blob); + return STORAGE_ID; + }), + delete: vi.fn(async () => undefined), + runMutation, + }); + + await captureWebSourcesBestEffort(context, captureArgs()); + + expect(storedBlobs).toHaveLength(1); + const storedBlob = storedBlobs[0]; + if (!storedBlob) throw new Error('Expected one stored evidence attachment.'); + const storedBytes = new Uint8Array(await storedBlob.arrayBuffer()); + const recordCalls = runMutation.mock.calls as unknown as Array<[unknown, unknown]>; + const recordArgs = recordCalls[0]?.[1] as + | { + contentDigest: string; + steps: Array<{ contentDigest: string; pdfStorageId?: string }>; + } + | undefined; + if (!recordArgs) throw new Error('Expected one evidence record mutation.'); + const expectedDigest = nodeslideContentDigest(storedBytes); + expect(recordArgs.contentDigest).toBe(expectedDigest); + expect(recordArgs.steps[0]).toMatchObject({ + contentDigest: expectedDigest, + pdfStorageId: STORAGE_ID, + }); + + const tamperedBytes = Uint8Array.from(storedBytes); + tamperedBytes[tamperedBytes.length - 1] ^= 0x01; + expect(nodeSlideEvidenceAttachmentDigest(tamperedBytes)).not.toBe(expectedDigest); + expect(expectedDigest).not.toBe( + nodeslideContentDigest('Quarterly report\nhttps://example.com/report\nRevenue was 42.'), + ); + }); + + it('persists only byte-verified whole-screenshot geometry for a production web capture', async () => { + vi.stubEnv('FIRECRAWL_API_KEY', 'capture-key'); + const screenshotBytes = pngHeader(1_280, 720); + vi.stubGlobal( + 'fetch', + vi.fn((input) => { + const url = String(input); + if (url === 'https://api.firecrawl.dev/v1/scrape') { + return Promise.resolve( + new Response( + JSON.stringify({ + success: true, + data: { + markdown: '# Captured report', + screenshot: 'https://cdn.example.com/report.png', + metadata: { title: 'Quarterly report' }, + }, + }), + { headers: { 'content-type': 'application/json' } }, + ), + ); + } + if (url === 'https://cdn.example.com/report.png') { + return Promise.resolve( + new Response( + screenshotBytes.buffer.slice( + screenshotBytes.byteOffset, + screenshotBytes.byteOffset + screenshotBytes.byteLength, + ) as ArrayBuffer, + { headers: { 'content-type': 'image/png' } }, + ), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }), + ); + const runMutation = vi.fn(async () => ({ created: true })); + const context = actionContext({ + store: vi.fn(async () => 'storage_evidence_screenshot'), + delete: vi.fn(async () => undefined), + runMutation, + }); + + await captureWebSourcesBestEffort(context, captureArgs()); + + const recordCalls = runMutation.mock.calls as unknown as Array<[unknown, unknown]>; + const recordArgs = recordCalls[0]?.[1] as + | { + provider: string; + steps: Array<{ + screenshotStorageId?: string; + box?: { x: number; y: number; w: number; h: number }; + viewport?: { width: number; height: number }; + }>; + } + | undefined; + if (!recordArgs) throw new Error('Expected one evidence record mutation.'); + expect(recordArgs.provider).toBe('firecrawl'); + expect(recordArgs.steps[0]).toMatchObject({ + screenshotStorageId: 'storage_evidence_screenshot', + box: { x: 0, y: 0, w: 1, h: 1 }, + viewport: { width: 1_280, height: 720 }, + regionScope: 'source', + }); + expect(recordArgs.steps[0]?.box).toEqual({ x: 0, y: 0, w: 1, h: 1 }); + }); + + it('deletes a stored orphan and propagates the record failure', async () => { + vi.stubEnv('FIRECRAWL_API_KEY', ''); + const recordError = new Error('recordEvidenceCaptureInternal failed'); + const deleteStorage = vi.fn(async () => undefined); + const context = actionContext({ + store: vi.fn(async () => STORAGE_ID), + delete: deleteStorage, + runMutation: vi.fn(async () => { + throw recordError; + }), + }); + + await expect(captureWebSourcesBestEffort(context, captureArgs())).rejects.toBe(recordError); + expect(deleteStorage).toHaveBeenCalledTimes(1); + expect(deleteStorage).toHaveBeenCalledWith(STORAGE_ID); + }); + + it('pairs [invalid URL, valid URL] results by stable source identity', () => { + const validUrl = 'https://example.com/retained-report#evidence'; + const normalizedValidUrl = new URL(validUrl).toString().slice(0, 900); + const validSourceId = nodeslideStableId('source_web', DECK_ID, normalizedValidUrl); + + const paired = pairNodeSlideStoredWebSources({ + deckId: DECK_ID, + inputs: [ + { + title: 'Invalid source must not rebind', + url: 'not a URL', + snippet: 'invalid excerpt', + provider: 'test', + }, + { + title: 'Retained valid source', + url: validUrl, + snippet: 'valid excerpt', + provider: 'test', + }, + ], + references: [{ id: validSourceId }], + }); + + expect(paired).toEqual([ + { + sourceId: validSourceId, + title: 'Retained valid source', + url: normalizedValidUrl, + snippet: 'valid excerpt', + provider: 'test', + }, + ]); + }); +}); + +function captureArgs() { + return { + deckId: DECK_ID, + ownerAccessKey: 'a'.repeat(43), + runId: 'run_evidence_hardening', + parentSpanId: 'span_research', + sources: [ + { + sourceId: 'source_valid', + title: 'Quarterly report', + url: 'https://example.com/report', + snippet: 'Revenue was 42.', + provider: 'test-search', + }, + ], + }; +} + +function actionContext(args: { + store: ReturnType; + delete: ReturnType; + runMutation: ReturnType; +}): ActionCtx { + return { + storage: { store: args.store, delete: args.delete }, + runMutation: args.runMutation, + } as unknown as ActionCtx; +} + +function pngHeader(width: number, height: number): Uint8Array { + return new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0, + 0, + 0, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, + (width >>> 24) & 0xff, + (width >>> 16) & 0xff, + (width >>> 8) & 0xff, + width & 0xff, + (height >>> 24) & 0xff, + (height >>> 16) & 0xff, + (height >>> 8) & 0xff, + height & 0xff, + ]); +} diff --git a/convex/lib/nodeslideCandidate.test.ts b/convex/lib/nodeslideCandidate.test.ts index bd756ff..d13d5db 100644 --- a/convex/lib/nodeslideCandidate.test.ts +++ b/convex/lib/nodeslideCandidate.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; -import type { DeckPatch } from '../../shared/nodeslide'; +import type { DeckPatch, PatchOperation, PatchScope } from '../../shared/nodeslide'; import { candidateValidationBindingMatches, candidateValidationReceipt, + evaluateNodeSlideSemanticCoverage, materializeNodeSlideCandidate, nodeSlideCandidateDigest, nodeSlideCandidateValidationId, + nodeSlideSemanticCoverageReceiptMatches, } from './nodeslideCandidate'; import { buildGoldenNodeSlide } from './nodeslideSeed'; import { validateNodeSlideSnapshot } from './nodeslideValidation'; @@ -73,4 +75,113 @@ describe('NodeSlide candidate validation binding', () => { ); expect(nodeSlideCandidateDigest(changed)).not.toBe(digest); }); + + it('binds explicit field coverage to the exact candidate and rejects five-field under-coverage', () => { + const snapshot = buildGoldenNodeSlide('semantic-coverage-binding', 1_700_000_000_000).snapshot; + const slide = snapshot.slides[0]; + if (!slide) throw new Error('Expected opening slide fixture.'); + const elements = snapshot.elements.filter((element) => element.slideId === slide.id); + const section = elements.find((element) => element.name === 'Section label'); + if (!section) throw new Error('Expected section label fixture.'); + const scope: PatchScope = { + kind: 'slide', + deckId: snapshot.deck.id, + slideIds: [slide.id], + operationMode: 'unrestricted', + }; + const operations: PatchOperation[] = [ + { + op: 'move', + slideId: slide.id, + elementId: section.id, + x: section.bbox.x + 0.01, + y: section.bbox.y, + }, + ]; + const receipt = evaluateNodeSlideSemanticCoverage({ + snapshot, + instruction: + 'Rewrite the section label, headline, body copy, key point 1, and key point 2. Change nothing else.', + scope, + operations, + focusSlideId: slide.id, + }); + const candidate = materializeNodeSlideCandidate(snapshot, { scope, operations }, 10); + + expect(receipt.status).toBe('blocked'); + expect(receipt.obligations.map((obligation) => obligation.field)).toEqual([ + 'section label', + 'headline', + 'body copy', + 'key point 1', + 'key point 2', + ]); + expect(receipt.coveredObligationIds).toEqual([]); + expect(receipt.missingObligationIds).toHaveLength(5); + expect(nodeSlideSemanticCoverageReceiptMatches(receipt, candidate)).toBe(true); + expect( + nodeSlideSemanticCoverageReceiptMatches(receipt, { + ...candidate, + deck: { ...candidate.deck, title: 'Changed after coverage' }, + }), + ).toBe(false); + }); + + it('requires one matching headline edit for every explicitly numbered slide target', () => { + const snapshot = buildGoldenNodeSlide('semantic-slide-targets', 1_700_000_000_000).snapshot; + const slides = snapshot.slides.slice(0, 2); + const headlines = slides.map((slide) => { + const headline = snapshot.elements.find( + (element) => + element.slideId === slide.id && + !element.locked && + element.kind === 'text' && + (element.role === 'title' || element.role === 'headline'), + ); + if (!headline) throw new Error(`Expected headline for ${slide.id}.`); + return headline; + }); + const scope: PatchScope = { + kind: 'slide', + deckId: snapshot.deck.id, + slideIds: slides.map((slide) => slide.id), + operationMode: 'copy', + }; + const operations: PatchOperation[] = [ + { + op: 'replace_text', + slideId: headlines[0]?.slideId ?? '', + elementId: headlines[0]?.id ?? '', + text: 'First headline updated.', + }, + ]; + + const blocked = evaluateNodeSlideSemanticCoverage({ + snapshot, + instruction: 'Rewrite the headline on slides 1 and 2.', + scope, + operations, + }); + const passed = evaluateNodeSlideSemanticCoverage({ + snapshot, + instruction: 'Rewrite the headline on slides 1 and 2.', + scope, + operations: [ + ...operations, + { + op: 'replace_text', + slideId: headlines[1]?.slideId ?? '', + elementId: headlines[1]?.id ?? '', + text: 'Second headline updated.', + }, + ], + }); + + expect(blocked.status).toBe('blocked'); + expect(blocked.obligations).toHaveLength(2); + expect(blocked.coveredObligationIds).toHaveLength(1); + expect(blocked.missingObligationIds).toHaveLength(1); + expect(passed.status).toBe('pass'); + expect(passed.coveredObligationIds).toHaveLength(2); + }); }); diff --git a/convex/lib/nodeslideCandidate.ts b/convex/lib/nodeslideCandidate.ts index f73bedf..dca5dd8 100644 --- a/convex/lib/nodeslideCandidate.ts +++ b/convex/lib/nodeslideCandidate.ts @@ -2,11 +2,57 @@ import type { CandidateValidationReceipt, DeckPatch, DeckSnapshot, + PatchOperation, + PatchScope, + SlideElement, ValidationResult, } from '../../shared/nodeslide'; import { applyDeckPatch } from '../../shared/nodeslidePatch'; import { nodeslideContentDigest, nodeslideStableId } from './nodeslideIds'; +export const NODESLIDE_SEMANTIC_COVERAGE_SCHEMA_VERSION = 'nodeslide.semantic-coverage/v1' as const; + +export type NodeSlideSemanticCoverageStatus = 'not_applicable' | 'pass' | 'blocked'; +export type NodeSlideSemanticOperationClass = + | 'copy' + | 'style' + | 'layout' + | 'chart' + | 'remove' + | 'visibility'; + +export interface NodeSlideSemanticCoverageObligation { + id: string; + field: string; + slideId: string; + elementId: string | null; + operationClass: NodeSlideSemanticOperationClass; + expectedText?: string; + expectedVisibility?: boolean; +} + +export interface NodeSlideSemanticCoverageReceipt { + schemaVersion: typeof NODESLIDE_SEMANTIC_COVERAGE_SCHEMA_VERSION; + id: string; + status: NodeSlideSemanticCoverageStatus; + instructionDigest: string; + baseSnapshotDigest: string; + candidateDigest: string; + operationsDigest: string; + obligations: NodeSlideSemanticCoverageObligation[]; + coveredObligationIds: string[]; + missingObligationIds: string[]; + digest: string; +} + +interface NodeSlideSemanticCoverageInput { + snapshot: DeckSnapshot; + instruction: string; + scope: PatchScope; + operations: readonly PatchOperation[]; + focusSlideId?: string; +} + export function materializeNodeSlideCandidate( snapshot: DeckSnapshot, patch: Pick, @@ -92,6 +138,444 @@ export function candidateValidationBindingMatches(args: { return validationSemanticDigest(args.validation) === validationSemanticDigest(receipt); } +/** + * Proves that a bounded candidate covers every field/role/slide target the user explicitly + * enumerated. Structural validation answers "is this operation legal?"; this receipt answers + * the separate question "does this operation address the requested work?". + */ +export function evaluateNodeSlideSemanticCoverage( + input: NodeSlideSemanticCoverageInput, +): NodeSlideSemanticCoverageReceipt { + const obligations = semanticCoverageObligations(input); + const coveredObligationIds = obligations + .filter((obligation) => operationCoversObligation(input.operations, obligation)) + .map((obligation) => obligation.id); + const covered = new Set(coveredObligationIds); + const missingObligationIds = obligations + .filter((obligation) => !covered.has(obligation.id)) + .map((obligation) => obligation.id); + const status: NodeSlideSemanticCoverageStatus = + obligations.length === 0 + ? 'not_applicable' + : missingObligationIds.length === 0 + ? 'pass' + : 'blocked'; + const candidate = materializeNodeSlideCandidate( + input.snapshot, + { scope: input.scope, operations: [...input.operations] }, + 0, + ); + const partial = { + schemaVersion: NODESLIDE_SEMANTIC_COVERAGE_SCHEMA_VERSION, + id: nodeslideStableId( + 'semantic_coverage', + nodeslideContentDigest(input.instruction), + nodeslideContentDigest(stableJson(input.operations)), + ), + status, + instructionDigest: nodeslideContentDigest(input.instruction), + baseSnapshotDigest: nodeSlideCandidateDigest(input.snapshot), + candidateDigest: nodeSlideCandidateDigest(candidate), + operationsDigest: nodeslideContentDigest(stableJson(input.operations)), + obligations, + coveredObligationIds, + missingObligationIds, + }; + return { ...partial, digest: nodeslideContentDigest(stableJson(partial)) }; +} + +export function nodeSlideSemanticCoverageReceiptMatches( + receipt: NodeSlideSemanticCoverageReceipt, + candidate: DeckSnapshot, +): boolean { + const { digest, ...partial } = receipt; + const obligationIds = receipt.obligations.map((obligation) => obligation.id); + const uniqueObligationIds = new Set(obligationIds); + const coveredIds = new Set(receipt.coveredObligationIds); + const missingIds = new Set(receipt.missingObligationIds); + const expectedMissingIds = obligationIds.filter((id) => !coveredIds.has(id)); + return ( + receipt.schemaVersion === NODESLIDE_SEMANTIC_COVERAGE_SCHEMA_VERSION && + receipt.candidateDigest === nodeSlideCandidateDigest(candidate) && + digest === nodeslideContentDigest(stableJson(partial)) && + uniqueObligationIds.size === obligationIds.length && + coveredIds.size === receipt.coveredObligationIds.length && + missingIds.size === receipt.missingObligationIds.length && + receipt.coveredObligationIds.every((id) => uniqueObligationIds.has(id)) && + receipt.missingObligationIds.every((id) => uniqueObligationIds.has(id)) && + expectedMissingIds.length === missingIds.size && + expectedMissingIds.every((id) => missingIds.has(id)) && + (receipt.status === 'not_applicable' + ? receipt.obligations.length === 0 && receipt.missingObligationIds.length === 0 + : receipt.status === 'pass' + ? receipt.obligations.length > 0 && receipt.missingObligationIds.length === 0 + : receipt.obligations.length > 0 && receipt.missingObligationIds.length > 0) + ); +} + +interface SemanticFieldDescriptor { + field: string; + matches: (element: SlideElement) => boolean; +} + +function semanticCoverageObligations( + input: NodeSlideSemanticCoverageInput, +): NodeSlideSemanticCoverageObligation[] { + const instruction = normalizedInstruction(input.instruction); + const descriptors = requestedSemanticFields(instruction); + const exactReplacement = exactReplacementIntent(input.instruction); + const scoped = semanticScopedElements(input.snapshot, input.scope); + const requestedSlideIds = semanticRequestedSlideIds(input, instruction); + const operationClass = requestedSemanticOperationClass(instruction, descriptors); + const expectedVisibility = requestedVisibility(instruction, operationClass); + const obligations: NodeSlideSemanticCoverageObligation[] = []; + const singleScopedElementId = + input.scope.kind !== 'deck' && + 'elementIds' in input.scope && + input.scope.elementIds.length === 1 + ? input.scope.elementIds[0] + : undefined; + + for (const descriptor of descriptors) { + let targets = scoped.filter( + (element) => requestedSlideIds.has(element.slideId) && descriptor.matches(element), + ); + if (targets.length === 0 && descriptors.length === 1 && singleScopedElementId) { + targets = scoped.filter((element) => element.id === singleScopedElementId); + } + if (targets.length === 0) { + for (const slideId of requestedSlideIds) { + obligations.push( + semanticObligation({ + field: descriptor.field, + slideId, + elementId: null, + operationClass, + ...(exactReplacement?.expected && descriptors.length === 1 + ? { expectedText: exactReplacement.expected } + : {}), + ...(expectedVisibility === undefined ? {} : { expectedVisibility }), + }), + ); + } + continue; + } + for (const target of targets) { + obligations.push( + semanticObligation({ + field: descriptor.field, + slideId: target.slideId, + elementId: target.id, + operationClass, + ...(exactReplacement?.expected && descriptors.length === 1 + ? { expectedText: exactReplacement.expected } + : {}), + ...(expectedVisibility === undefined ? {} : { expectedVisibility }), + }), + ); + } + } + + // Exact element-scoped replacements are explicit even when the user names the old text + // instead of a semantic role (for example: Replace "Before" with "After"). + if (descriptors.length === 0 && exactReplacement) { + const exactTargets = scoped.filter( + (element) => + requestedSlideIds.has(element.slideId) && + element.kind === 'text' && + (element.content === exactReplacement.current || + (input.scope.kind !== 'deck' && + 'elementIds' in input.scope && + input.scope.elementIds.length === 1)), + ); + for (const target of exactTargets) { + obligations.push( + semanticObligation({ + field: target.name || target.role || 'selected text', + slideId: target.slideId, + elementId: target.id, + operationClass: 'copy', + expectedText: exactReplacement.expected, + }), + ); + } + } + + return dedupeObligations(obligations); +} + +function semanticObligation( + input: Omit, +): NodeSlideSemanticCoverageObligation { + return { + id: nodeslideStableId( + 'semantic_obligation', + input.field, + input.slideId, + input.elementId ?? 'missing', + input.operationClass, + input.expectedText ?? '', + input.expectedVisibility === undefined ? '' : String(input.expectedVisibility), + ), + ...input, + }; +} + +function requestedSemanticFields(instruction: string): SemanticFieldDescriptor[] { + const descriptors: SemanticFieldDescriptor[] = []; + const add = (descriptor: SemanticFieldDescriptor) => { + if (!descriptors.some((candidate) => candidate.field === descriptor.field)) { + descriptors.push(descriptor); + } + }; + if (/\bsection\s+(?:label|title|name)\b/u.test(instruction)) { + add( + fieldDescriptor('section label', (element) => + /\bsection\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + if (/\b(?:headline|heading|slide\s+title)\b/u.test(instruction)) { + add( + fieldDescriptor('headline', (element) => + /\b(?:headline|heading|title)\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + if (/\b(?:body(?:\s+copy)?|paragraph|description)\b/u.test(instruction)) { + add( + fieldDescriptor('body copy', (element) => + /\b(?:body|paragraph|description)\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + const numberedPoints = [...instruction.matchAll(/\b(?:key\s+point|bullet)\s*(\d+)\b/gu)]; + for (const match of numberedPoints) { + const index = match[1]; + if (!index) continue; + add( + fieldDescriptor(`key point ${index}`, (element) => + new RegExp(`\\b(?:key\\s+point|bullet)\\s*${index}\\b`, 'u').test( + normalizedElementIdentity(element), + ), + ), + ); + } + if (numberedPoints.length === 0 && /\b(?:key\s+points|bullets)\b/u.test(instruction)) { + add( + fieldDescriptor('key points', (element) => + /\b(?:bullet|key\s+point)\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + if (/\bfooter\b/u.test(instruction)) { + add( + fieldDescriptor('footer', (element) => + /\bfooter\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + if (/\b(?:page|slide)\s+number\b/u.test(instruction)) { + add( + fieldDescriptor('page number', (element) => + /\b(?:page|slide)[ _-]*number\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + if (/\bcaption\b/u.test(instruction)) { + add( + fieldDescriptor('caption', (element) => + /\bcaption\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + if (/\b(?:metric|kpi)\b/u.test(instruction)) { + add( + fieldDescriptor('metric', (element) => + /\b(?:metric|kpi)\b/u.test(normalizedElementIdentity(element)), + ), + ); + } + if (/\b(?:chart|graph)\b/u.test(instruction)) { + add(fieldDescriptor('chart', (element) => element.kind === 'chart')); + } + if (/\b(?:formula|equation|math)\b/u.test(instruction)) { + add(fieldDescriptor('formula', (element) => element.kind === 'math')); + } + if (/\bimage\b/u.test(instruction)) { + add(fieldDescriptor('image', (element) => element.kind === 'image')); + } + return descriptors; +} + +function fieldDescriptor( + field: string, + matches: (element: SlideElement) => boolean, +): SemanticFieldDescriptor { + return { field, matches }; +} + +function requestedSemanticOperationClass( + instruction: string, + descriptors: readonly SemanticFieldDescriptor[], +): NodeSlideSemanticOperationClass { + if (/\b(?:delete|remove|erase)\b/u.test(instruction)) return 'remove'; + if (/\b(?:hide|unhide|reveal|visible|invisible|visibility)\b/u.test(instruction)) { + return 'visibility'; + } + if (descriptors.some((descriptor) => descriptor.field === 'chart')) { + return 'chart'; + } + if (/\b(?:move|position|align|layout|resize|spacing|place)\b/u.test(instruction)) return 'layout'; + const explicitlyRequestsCopy = + /\b(?:replace|rewrite|copy|text|wording|say|read|shorten|summarize)\b/u.test(instruction); + if ( + /\b(?:style|color|font|weight|bold|appearance|theme|contrast|decisive|assertive|stronger|emphasis|accent)\b/u.test( + instruction, + ) && + !explicitlyRequestsCopy + ) { + return 'style'; + } + return descriptors.length > 0 ? 'copy' : 'copy'; +} + +function requestedVisibility( + instruction: string, + operationClass: NodeSlideSemanticOperationClass, +): boolean | undefined { + if (operationClass !== 'visibility') return undefined; + if ( + /\b(?:hide|conceal|invisible)\b/u.test(instruction) || + /\bvisibility\s+to\s+false\b/u.test(instruction) + ) { + return false; + } + if ( + /\b(?:unhide|reveal)\b/u.test(instruction) || + /\bvisibility\s+to\s+true\b/u.test(instruction) + ) { + return true; + } + return undefined; +} + +function semanticRequestedSlideIds( + input: NodeSlideSemanticCoverageInput, + instruction: string, +): Set { + const scopedSlideIds = + input.scope.kind === 'deck' ? input.snapshot.deck.slideOrder : input.scope.slideIds; + const explicitNumbers = explicitRequestedSlideNumbers(instruction) + .filter((value) => Number.isSafeInteger(value) && value >= 1) + .map((value) => input.snapshot.deck.slideOrder[value - 1]) + .filter( + (value): value is string => typeof value === 'string' && scopedSlideIds.includes(value), + ); + if (explicitNumbers.length > 0) return new Set(explicitNumbers); + if (/\b(?:selected|these|both|all|every|each|multiple)\s+slides?\b/u.test(instruction)) { + return new Set(scopedSlideIds); + } + if (input.focusSlideId && scopedSlideIds.includes(input.focusSlideId)) { + return new Set([input.focusSlideId]); + } + return new Set(scopedSlideIds.length === 1 ? scopedSlideIds : scopedSlideIds.slice(0, 1)); +} + +function explicitRequestedSlideNumbers(instruction: string): number[] { + const values = [...instruction.matchAll(/\bslide\s+(\d+)\b/gu)].flatMap((match) => + match[1] ? [Number(match[1])] : [], + ); + for (const match of instruction.matchAll(/\bslides\s+((?:\d+\s*(?:(?:,|and|&|to|-)\s*)?)+)/gu)) { + const segment = match[1] ?? ''; + const numbers = [...segment.matchAll(/\d+/gu)].map((item) => Number(item[0])); + if (/\bto\b|-/u.test(segment) && numbers.length === 2) { + const start = numbers[0] ?? 0; + const end = numbers[1] ?? 0; + if (start >= 1 && end >= start && end - start <= 8) { + for (let value = start; value <= end; value += 1) values.push(value); + continue; + } + } + values.push(...numbers); + } + return [...new Set(values)]; +} + +function semanticScopedElements(snapshot: DeckSnapshot, scope: PatchScope): SlideElement[] { + const slideIds = new Set(scope.kind === 'deck' ? snapshot.deck.slideOrder : scope.slideIds); + const elementIds = + scope.kind !== 'deck' && 'elementIds' in scope ? new Set(scope.elementIds) : null; + return snapshot.elements.filter( + (element) => + slideIds.has(element.slideId) && + (!elementIds || elementIds.has(element.id)) && + !element.locked && + element.visible !== false, + ); +} + +function normalizedElementIdentity(element: SlideElement): string { + return normalizedInstruction(`${element.role ?? ''} ${element.name}`); +} + +function normalizedInstruction(value: string): string { + return value.toLowerCase().replace(/[_-]+/gu, ' ').replace(/\s+/gu, ' ').trim(); +} + +function exactReplacementIntent( + instruction: string, +): { current?: string; expected: string } | null { + const replacement = instruction.match(/\breplace\s+["“]([^"”]+)["”]\s+with\s+["“]([^"”]+)["”]/iu); + if (replacement?.[1] && replacement[2]) { + return { current: replacement[1], expected: replacement[2] }; + } + if (!/\bexact(?:ly)?\b/iu.test(instruction)) return null; + const quoted = [...instruction.matchAll(/["“]([^"”]+)["”]/gu)]; + const expected = quoted.at(-1)?.[1]; + return expected ? { expected } : null; +} + +function operationCoversObligation( + operations: readonly PatchOperation[], + obligation: NodeSlideSemanticCoverageObligation, +): boolean { + if (!obligation.elementId) return false; + return operations.some((operation) => { + if (!('elementId' in operation)) return false; + if (operation.slideId !== obligation.slideId || operation.elementId !== obligation.elementId) { + return false; + } + if (obligation.operationClass === 'copy') { + return ( + operation.op === 'replace_text' && + (obligation.expectedText === undefined || operation.text === obligation.expectedText) + ); + } + if (obligation.operationClass === 'style') return operation.op === 'update_style'; + if (obligation.operationClass === 'layout') { + return ( + operation.op === 'move' || + operation.op === 'resize' || + operation.op === 'reorder_element_v1' + ); + } + if (obligation.operationClass === 'chart') return operation.op === 'update_chart'; + if (obligation.operationClass === 'remove') return operation.op === 'remove_element'; + return ( + operation.op === 'set_visibility_v1' && + (obligation.expectedVisibility === undefined || + operation.visible === obligation.expectedVisibility) + ); + }); +} + +function dedupeObligations( + obligations: readonly NodeSlideSemanticCoverageObligation[], +): NodeSlideSemanticCoverageObligation[] { + return [...new Map(obligations.map((obligation) => [obligation.id, obligation])).values()]; +} + function validationSemanticDigest( validation: ValidationResult | CandidateValidationReceipt, ): string { diff --git a/convex/lib/nodeslideClaimEvidenceReceipt.test.ts b/convex/lib/nodeslideClaimEvidenceReceipt.test.ts new file mode 100644 index 0000000..bfdd149 --- /dev/null +++ b/convex/lib/nodeslideClaimEvidenceReceipt.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it } from 'vitest'; +import { + type BuildNodeSlideClaimEvidenceReceiptArgs, + assertNodeSlideClaimEvidenceReceipt, + buildNodeSlideClaimEvidenceReceipt, + isNodeSlideClaimEvidenceReceipt, + normalizeNodeSlideEvidenceRegion, +} from './nodeslideClaimEvidenceReceipt'; + +const DIGESTS = { + claim: `sha256:${'1'.repeat(64)}`, + sourceRevision: `sha256:${'2'.repeat(64)}`, + capture: `sha256:${'3'.repeat(64)}`, + step: `sha256:${'4'.repeat(64)}`, + attachment: `sha256:${'5'.repeat(64)}`, +} as const; + +describe('NodeSlide claim evidence receipts', () => { + it('builds a deterministic, deeply frozen claim-to-region custody receipt', () => { + const input = receiptArgs(); + const original = structuredClone(input); + const first = buildNodeSlideClaimEvidenceReceipt(input); + const second = buildNodeSlideClaimEvidenceReceipt({ + ...input, + region: { x: 0.10000001, y: 0.2, w: 0.3, h: 0.4, page: 2, pageCount: 8 }, + }); + + expect(first).toEqual(second); + expect(first).toMatchObject({ + schema: 'nodeslide.claim-evidence-receipt/v1', + claimDigest: DIGESTS.claim, + sourceRevisionId: `source-revision:${DIGESTS.sourceRevision}`, + sourceRevisionDigest: DIGESTS.sourceRevision, + captureId: 'capture-a', + captureDigest: DIGESTS.capture, + evidenceStepId: 'step-a', + evidenceStepDigest: DIGESTS.step, + attachmentKind: 'pdf', + attachmentDigest: DIGESTS.attachment, + region: { x: 0.1, y: 0.2, w: 0.3, h: 0.4, page: 2, pageCount: 8 }, + receiptId: expect.stringMatching(/^claim-evidence-receipt:sha256:[0-9a-f]{64}$/), + receiptDigest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + }); + expect(first.receiptId).toBe(`claim-evidence-receipt:${first.receiptDigest}`); + expect(input).toEqual(original); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.region)).toBe(true); + expect(() => assertNodeSlideClaimEvidenceReceipt(first)).not.toThrow(); + expect(isNodeSlideClaimEvidenceReceipt(first)).toBe(true); + }); + + it('normalizes valid screenshot boxes and rejects PDF-only page metadata', () => { + expect( + normalizeNodeSlideEvidenceRegion('screenshot', { + x: 0, + y: 0.123456789, + w: 1, + h: 0.876543211, + }), + ).toEqual({ x: 0, y: 0.123457, w: 1, h: 0.876543 }); + expect(() => + normalizeNodeSlideEvidenceRegion('screenshot', { + x: 0, + y: 0, + w: 1, + h: 1, + page: 1, + }), + ).toThrow('screenshot regions cannot contain PDF page bounds'); + }); + + it('enforces normalized geometry and exact PDF page bounds', () => { + const invalidRegions = [ + { x: -0.01, y: 0, w: 0.5, h: 0.5 }, + { x: 0, y: 0, w: 0, h: 0.5 }, + { x: 0.8, y: 0, w: 0.3, h: 0.5 }, + { x: 0, y: 0.8, w: 0.5, h: 0.3 }, + { x: Number.NaN, y: 0, w: 0.5, h: 0.5 }, + ]; + for (const region of invalidRegions) { + expect(() => normalizeNodeSlideEvidenceRegion('screenshot', region)).toThrow(); + } + + expect(() => normalizeNodeSlideEvidenceRegion('pdf', { x: 0, y: 0, w: 1, h: 1 })).toThrow( + 'PDF region page must be a positive safe integer', + ); + expect(() => + normalizeNodeSlideEvidenceRegion('pdf', { + x: 0, + y: 0, + w: 1, + h: 1, + page: 9, + pageCount: 8, + }), + ).toThrow('PDF region page exceeds the retained page count'); + expect(() => + normalizeNodeSlideEvidenceRegion('pdf', { + x: 0, + y: 0, + w: 1, + h: 1, + page: 1, + pageCount: 100_001, + }), + ).toThrow('PDF page count exceeds 100000'); + }); + + it('rejects every broken digest or identity link in the custody chain', () => { + const valid = receiptArgs(); + const invalid: BuildNodeSlideClaimEvidenceReceiptArgs[] = [ + { ...valid, claimDigest: 'sha256:short' }, + { + ...valid, + sourceRevisionId: `source-revision:sha256:${'9'.repeat(64)}`, + }, + { ...valid, sourceRevisionDigest: 'not-a-digest' }, + { ...valid, captureId: ' capture-a' }, + { ...valid, captureDigest: 'not-a-digest' }, + { ...valid, evidenceStepId: '' }, + { ...valid, evidenceStepDigest: 'not-a-digest' }, + { ...valid, attachmentDigest: 'not-a-digest' }, + ]; + + for (const input of invalid) { + expect(() => buildNodeSlideClaimEvidenceReceipt(input)).toThrow(); + } + }); + + it('detects post-build tampering at every material custody hop', () => { + const receipt = buildNodeSlideClaimEvidenceReceipt(receiptArgs()); + const mutations: unknown[] = [ + { ...receipt, claimDigest: `sha256:${'9'.repeat(64)}` }, + { ...receipt, sourceRevisionDigest: `sha256:${'9'.repeat(64)}` }, + { ...receipt, captureId: 'capture-b' }, + { ...receipt, captureDigest: `sha256:${'9'.repeat(64)}` }, + { ...receipt, evidenceStepId: 'step-b' }, + { ...receipt, evidenceStepDigest: `sha256:${'9'.repeat(64)}` }, + { ...receipt, attachmentDigest: `sha256:${'9'.repeat(64)}` }, + { ...receipt, region: { ...receipt.region, page: 3 } }, + { ...receipt, receiptDigest: `sha256:${'9'.repeat(64)}` }, + { ...receipt, receiptId: `claim-evidence-receipt:sha256:${'9'.repeat(64)}` }, + { ...receipt, unboundExtension: true }, + ]; + + for (const tampered of mutations) { + expect(() => assertNodeSlideClaimEvidenceReceipt(tampered)).toThrow(); + expect(isNodeSlideClaimEvidenceReceipt(tampered)).toBe(false); + } + }); +}); + +function receiptArgs(): BuildNodeSlideClaimEvidenceReceiptArgs { + return { + deckId: 'deck-a', + slideId: 'slide-a', + elementId: 'element-a', + claimDigest: DIGESTS.claim, + sourceRevisionId: `source-revision:${DIGESTS.sourceRevision}`, + sourceRevisionDigest: DIGESTS.sourceRevision, + captureId: 'capture-a', + captureDigest: DIGESTS.capture, + evidenceStepId: 'step-a', + evidenceStepDigest: DIGESTS.step, + attachmentKind: 'pdf', + attachmentDigest: DIGESTS.attachment, + region: { x: 0.1, y: 0.2, w: 0.3, h: 0.4, page: 2, pageCount: 8 }, + }; +} diff --git a/convex/lib/nodeslideClaimEvidenceReceipt.ts b/convex/lib/nodeslideClaimEvidenceReceipt.ts new file mode 100644 index 0000000..f881bc4 --- /dev/null +++ b/convex/lib/nodeslideClaimEvidenceReceipt.ts @@ -0,0 +1,289 @@ +import { nodeslideContentDigest } from './nodeslideIds'; + +export const NODESLIDE_CLAIM_EVIDENCE_RECEIPT_SCHEMA = + 'nodeslide.claim-evidence-receipt/v1' as const; + +const SHA_256_DIGEST = /^sha256:[0-9a-f]{64}$/; +const SOURCE_REVISION_ID = /^source-revision:sha256:[0-9a-f]{64}$/; +const RECEIPT_ID = /^claim-evidence-receipt:sha256:[0-9a-f]{64}$/; +const MAX_ID_LENGTH = 256; +const MAX_PDF_PAGES = 100_000; +const COORDINATE_PRECISION = 1_000_000; + +export type NodeSlideEvidenceRegionKind = 'screenshot' | 'pdf'; + +export interface NodeSlideEvidenceRegionInput { + x: number; + y: number; + w: number; + h: number; + /** One-indexed and required for PDF evidence. */ + page?: number; + /** Exact retained document page count; required for PDF evidence. */ + pageCount?: number; +} + +export interface NodeSlideNormalizedEvidenceRegion { + readonly x: number; + readonly y: number; + readonly w: number; + readonly h: number; + readonly page?: number; + readonly pageCount?: number; +} + +/** Exact immutable custody chain from one generated claim to one visual source region. */ +export interface NodeSlideClaimEvidenceReceipt { + readonly schema: typeof NODESLIDE_CLAIM_EVIDENCE_RECEIPT_SCHEMA; + readonly receiptId: string; + readonly receiptDigest: string; + readonly deckId: string; + readonly slideId: string; + readonly elementId: string; + readonly claimDigest: string; + readonly sourceRevisionId: string; + readonly sourceRevisionDigest: string; + readonly captureId: string; + readonly captureDigest: string; + readonly evidenceStepId: string; + readonly evidenceStepDigest: string; + readonly attachmentKind: NodeSlideEvidenceRegionKind; + readonly attachmentDigest: string; + readonly region: NodeSlideNormalizedEvidenceRegion; +} + +export interface BuildNodeSlideClaimEvidenceReceiptArgs { + deckId: string; + slideId: string; + elementId: string; + claimDigest: string; + sourceRevisionId: string; + sourceRevisionDigest: string; + captureId: string; + captureDigest: string; + evidenceStepId: string; + evidenceStepDigest: string; + attachmentKind: NodeSlideEvidenceRegionKind; + attachmentDigest: string; + region: NodeSlideEvidenceRegionInput; +} + +type ReceiptPayload = Omit; + +export function buildNodeSlideClaimEvidenceReceipt( + args: BuildNodeSlideClaimEvidenceReceiptArgs, +): NodeSlideClaimEvidenceReceipt { + const payload: ReceiptPayload = { + schema: NODESLIDE_CLAIM_EVIDENCE_RECEIPT_SCHEMA, + deckId: requiredId(args.deckId, 'deck ID'), + slideId: requiredId(args.slideId, 'slide ID'), + elementId: requiredId(args.elementId, 'element ID'), + claimDigest: canonicalDigest(args.claimDigest, 'claim digest'), + sourceRevisionId: canonicalSourceRevisionId(args.sourceRevisionId, args.sourceRevisionDigest), + sourceRevisionDigest: canonicalDigest(args.sourceRevisionDigest, 'source revision digest'), + captureId: requiredId(args.captureId, 'capture ID'), + captureDigest: canonicalDigest(args.captureDigest, 'capture digest'), + evidenceStepId: requiredId(args.evidenceStepId, 'evidence step ID'), + evidenceStepDigest: canonicalDigest(args.evidenceStepDigest, 'evidence step digest'), + attachmentKind: canonicalAttachmentKind(args.attachmentKind), + attachmentDigest: canonicalDigest(args.attachmentDigest, 'attachment digest'), + region: normalizeNodeSlideEvidenceRegion(args.attachmentKind, args.region), + }; + const receiptDigest = receiptPayloadDigest(payload); + const receipt = { + ...payload, + receiptId: `claim-evidence-receipt:${receiptDigest}`, + receiptDigest, + } satisfies NodeSlideClaimEvidenceReceipt; + assertNodeSlideClaimEvidenceReceipt(receipt); + return deepFreeze(receipt); +} + +/** + * Validates a normalized region without changing its semantic target. Six decimal + * places are retained to keep receipts deterministic across renderer boundaries. + */ +export function normalizeNodeSlideEvidenceRegion( + kind: NodeSlideEvidenceRegionKind, + input: NodeSlideEvidenceRegionInput, +): NodeSlideNormalizedEvidenceRegion { + canonicalAttachmentKind(kind); + if (!isRecord(input)) invalid('region must be an object'); + const x = normalizedCoordinate(input.x, 'region x', true); + const y = normalizedCoordinate(input.y, 'region y', true); + const w = normalizedCoordinate(input.w, 'region width', false); + const h = normalizedCoordinate(input.h, 'region height', false); + if (rounded(x + w) > 1 || rounded(y + h) > 1) { + invalid('region must fit within normalized page bounds'); + } + + if (kind === 'pdf') { + const page = positiveInteger(input.page, 'PDF region page'); + const pageCount = positiveInteger(input.pageCount, 'PDF region page count'); + if (pageCount > MAX_PDF_PAGES) invalid(`PDF page count exceeds ${MAX_PDF_PAGES}`); + if (page > pageCount) invalid('PDF region page exceeds the retained page count'); + return deepFreeze({ x, y, w, h, page, pageCount }); + } + if (input.page !== undefined || input.pageCount !== undefined) { + invalid('screenshot regions cannot contain PDF page bounds'); + } + return deepFreeze({ x, y, w, h }); +} + +/** Rejects a broken custody link, invalid page box, or any post-build tampering. */ +export function assertNodeSlideClaimEvidenceReceipt( + value: unknown, +): asserts value is NodeSlideClaimEvidenceReceipt { + if (!isRecord(value)) invalid('must be an object'); + const allowed = new Set([ + 'schema', + 'receiptId', + 'receiptDigest', + 'deckId', + 'slideId', + 'elementId', + 'claimDigest', + 'sourceRevisionId', + 'sourceRevisionDigest', + 'captureId', + 'captureDigest', + 'evidenceStepId', + 'evidenceStepDigest', + 'attachmentKind', + 'attachmentDigest', + 'region', + ]); + if (Object.keys(value).some((key) => !allowed.has(key))) { + invalid('contains fields outside the immutable receipt contract'); + } + if (value['schema'] !== NODESLIDE_CLAIM_EVIDENCE_RECEIPT_SCHEMA) invalid('schema is invalid'); + if (typeof value['receiptId'] !== 'string' || !RECEIPT_ID.test(value['receiptId'])) { + invalid('receipt ID is invalid'); + } + canonicalDigest(value['receiptDigest'], 'receipt digest'); + requiredId(value['deckId'], 'deck ID'); + requiredId(value['slideId'], 'slide ID'); + requiredId(value['elementId'], 'element ID'); + canonicalDigest(value['claimDigest'], 'claim digest'); + canonicalSourceRevisionId(value['sourceRevisionId'], value['sourceRevisionDigest']); + canonicalDigest(value['sourceRevisionDigest'], 'source revision digest'); + requiredId(value['captureId'], 'capture ID'); + canonicalDigest(value['captureDigest'], 'capture digest'); + requiredId(value['evidenceStepId'], 'evidence step ID'); + canonicalDigest(value['evidenceStepDigest'], 'evidence step digest'); + const attachmentKind = canonicalAttachmentKind(value['attachmentKind']); + canonicalDigest(value['attachmentDigest'], 'attachment digest'); + if (!isRecord(value['region'])) invalid('region must be an object'); + const canonicalRegion = normalizeNodeSlideEvidenceRegion( + attachmentKind, + value['region'] as unknown as NodeSlideEvidenceRegionInput, + ); + if (stableJson(canonicalRegion) !== stableJson(value['region'])) { + invalid('region is not canonical'); + } + + const { receiptId: _receiptId, receiptDigest, ...payload } = value; + const expectedDigest = receiptPayloadDigest(payload as ReceiptPayload); + if (receiptDigest !== expectedDigest) invalid('digest binding is invalid'); + if (value['receiptId'] !== `claim-evidence-receipt:${expectedDigest}`) { + invalid('receipt ID is not bound to its digest'); + } +} + +export function isNodeSlideClaimEvidenceReceipt( + value: unknown, +): value is NodeSlideClaimEvidenceReceipt { + try { + assertNodeSlideClaimEvidenceReceipt(value); + return true; + } catch { + return false; + } +} + +function receiptPayloadDigest(payload: ReceiptPayload): string { + return nodeslideContentDigest(stableJson(payload)); +} + +function canonicalSourceRevisionId(value: unknown, digest: unknown): string { + const revisionId = requiredId(value, 'source revision ID'); + const revisionDigest = canonicalDigest(digest, 'source revision digest'); + if (!SOURCE_REVISION_ID.test(revisionId) || revisionId !== `source-revision:${revisionDigest}`) { + invalid('source revision ID is not bound to its digest'); + } + return revisionId; +} + +function canonicalAttachmentKind(value: unknown): NodeSlideEvidenceRegionKind { + if (value !== 'screenshot' && value !== 'pdf') invalid('attachment kind is invalid'); + return value; +} + +function canonicalDigest(value: unknown, label: string): string { + if (typeof value !== 'string' || !SHA_256_DIGEST.test(value)) { + invalid(`${label} must be a canonical SHA-256 digest`); + } + return value; +} + +function requiredId(value: unknown, label: string): string { + if ( + typeof value !== 'string' || + value.length === 0 || + value.length > MAX_ID_LENGTH || + value.trim() !== value + ) { + invalid(`${label} must be non-empty, trimmed, and at most ${MAX_ID_LENGTH} characters`); + } + return value; +} + +function normalizedCoordinate(value: unknown, label: string, zeroAllowed: boolean): number { + if (typeof value !== 'number' || !Number.isFinite(value)) invalid(`${label} must be finite`); + const normalized = rounded(value); + if (normalized < 0 || normalized > 1 || (!zeroAllowed && normalized === 0)) { + invalid(`${label} must be ${zeroAllowed ? 'between 0 and 1' : 'greater than 0 and at most 1'}`); + } + return normalized; +} + +function positiveInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) <= 0) { + invalid(`${label} must be a positive safe integer`); + } + return Number(value); +} + +function rounded(value: number): number { + return Math.round(value * COORDINATE_PRECISION) / COORDINATE_PRECISION; +} + +function stableJson(value: unknown): string { + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value as Record)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function invalid(reason: string): never { + throw new Error(`NodeSlide claim evidence receipt ${reason}.`); +} diff --git a/convex/lib/nodeslideDataExport.test.ts b/convex/lib/nodeslideDataExport.test.ts index 7c11d15..3066cf1 100644 --- a/convex/lib/nodeslideDataExport.test.ts +++ b/convex/lib/nodeslideDataExport.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { NodeSlideOwnerDataExport } from '../../shared/nodeslideDataExport'; import type { QueryCtx } from '../_generated/server'; import { exportMyData } from '../nodeslideDataExport'; -import { nodeslideContentDigest } from './nodeslideIds'; +import { nodeslideContentDigest, nodeslideStableId } from './nodeslideIds'; import { nodeSlideJobOwnerDigest } from './nodeslideJobState'; const OWNER_ACCESS_KEY = 'a'.repeat(43); @@ -118,7 +118,7 @@ function seedDeck( }); } -function seedOwnerActivity(database: MemoryDatabase, deckId: string) { +function seedOwnerActivity(database: MemoryDatabase, deckId: string, budgetId?: string) { database.seed('nodeslide_agent_jobs', { id: 'job:owner', kind: 'edit_proposal', @@ -129,6 +129,8 @@ function seedOwnerActivity(database: MemoryDatabase, deckId: string) { idempotencyKey: 'job-private-key', streamId: 'stream-private', workflowId: 'workflow-private', + requestDigest: 'sha256:owner-request', + ...(budgetId ? { budgetId } : {}), error: `Never echo ${OWNER_ACCESS_KEY}`, createdAt: 40, updatedAt: 41, @@ -297,15 +299,25 @@ describe('NodeSlide owner data export', () => { }); const serialized = JSON.stringify(bundle); - expect(bundle.manifest.schemaVersion).toBe('nodeslide.owner-data-export/v1'); + expect(bundle.manifest.schemaVersion).toBe('nodeslide.owner-data-export/v2'); expect(bundle.manifest.completeness).toMatchObject({ status: 'complete', truncated: false }); expect(bundle.manifest.mutationPolicy).toBe('read_only_no_cas_or_proposal_state_changes'); expect(bundle.manifest.redaction.removedFieldCount).toBeGreaterThan(0); expect(bundle.manifest.redaction.redactedValueCount).toBeGreaterThan(0); expect(bundle.manifest.redaction.excludedCollections).toEqual( expect.arrayContaining([ - { name: 'nodeslide_oauth_credentials', reason: 'authentication_material' }, - { name: 'nodeslide_presence', reason: 'ephemeral_runtime_state' }, + expect.objectContaining({ + name: 'nodeslide_oauth_credentials', + reason: 'authentication_material', + }), + expect.objectContaining({ + name: 'nodeslide_presence', + reason: 'ephemeral_runtime_state', + }), + expect.objectContaining({ + name: 'nodeslide_deck_ci', + reason: 'computed_not_persisted', + }), ]), ); expect(bundle.data.deckSpec.deck).not.toHaveProperty('ownerAccessKey'); @@ -338,6 +350,218 @@ describe('NodeSlide owner data export', () => { expect(bundle.data.comments).toHaveLength(1); }); + it('exports deck-bound custody, evidence, sync, delegation, output, and budget metadata', async () => { + const database = new MemoryDatabase(); + const deckId = 'deck:custody'; + const budgetId = 'budget:owner'; + seedDeck(database, { deckId, ownerAccessKey: OWNER_ACCESS_KEY }); + seedOwnerActivity(database, deckId, budgetId); + database.seed('nodeslide_elements', { + id: 'element:inline-image', + deckId, + slideId: 'slide:one', + imageUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', + createdAt: 49, + }); + + const sessionId = nodeslideStableId('nsession', 'job:owner'); + const requestBinding = { + schemaVersion: 'nodeslide.request-binding/v2', + requestDigest: 'sha256:owner-request', + capabilityDigest: 'sha256:capability-proof', + }; + database.seed('nodeslide_durable_sessions', { + id: sessionId, + requestDigest: 'sha256:owner-request', + capabilityDigest: 'sha256:capability-proof', + requestBinding, + jobs: { 'job:owner': { requestBinding } }, + }); + database.seed('nodeslide_durable_session_events', { + sessionId, + jobId: 'job:owner', + requestBinding, + transitionSequence: 1, + occurredAt: 50, + }); + database.seed('nodeslide_durable_job_journal_entries', { + sessionId, + jobId: 'job:owner', + binding: { ...requestBinding, sessionId, jobId: 'job:owner' }, + sequence: 1, + createdAt: 51, + }); + database.seed('nodeslide_run_budgets', { + id: budgetId, + status: 'finalized', + actualMicroUsd: 1200, + createdAt: 52, + }); + database.seed('nodeslide_billable_calls', { + budgetId, + callId: 'call:one', + status: 'settled', + createdAt: 53, + }); + database.seed('nodeslide_budget_events', { + budgetId, + sequence: 1, + kind: 'settled', + createdAt: 54, + }); + database.seed('nodeslide_sync_connections', { + id: 'sync:one', + deckId, + provider: 'google_slides', + remotePresentationId: 'google:deck', + lastMutationKey: 'mutation-capability', + createdAt: 55, + }); + database.seed('nodeslide_delegation_grants', { + id: 'grant:one', + deckId, + tokenDigest: 'sha256:delegation-secret-digest', + capability: 'accept_validated', + createdAt: 56, + }); + database.seed('nodeslide_delegation_uses', { + id: 'use:one', + grantId: 'grant:one', + deckId, + patchId: 'patch:one', + usedAt: 57, + }); + database.seed('nodeslide_evidence_captures', { + id: 'capture:one', + deckId, + runId: 'run:owner', + traceId: 'trace:one', + spanId: 'span:one', + sourceId: 'source:one', + status: 'ready', + createdAt: 58, + }); + database.seed('nodeslide_evidence_steps', { + id: 'step:one', + captureId: 'capture:one', + deckId, + runId: 'run:owner', + traceId: 'trace:one', + spanId: 'span:one', + screenshotStorageId: 'storage:screenshot-capability', + pdfStorageId: 'storage:pdf-capability', + createdAt: 59, + }); + database.seed('nodeslide_shadow_comparisons', { + id: 'shadow:one', + deckId, + status: 'completed', + createdAt: 60, + }); + database.seed('nodeslide_exports', { + id: 'export:one', + deckId, + kind: 'pptx', + url: 'https://download.test/signed-export-capability', + createdAt: 61, + }); + database.seed('nodeslide_publications', { + id: 'publication:one', + deckId, + shareSlug: SHARE_CAPABILITY, + revision: 1, + snapshot: { deck: { id: deckId }, slides: [], elements: [], sources: [] }, + publishedAt: 62, + }); + database.seed('nodeslide_preference_events', { + id: 'preference:one', + deckId, + type: 'patch_accepted', + recordedAt: 63, + }); + + const bundle = await exportMyDataHandler(queryContext(database), { + deckId, + ownerAccessKey: OWNER_ACCESS_KEY, + }); + const serialized = JSON.stringify(bundle); + + expect(bundle.data.activity.durableSessions).toHaveLength(1); + expect(bundle.data.activity.durableSessionEvents).toHaveLength(1); + expect(bundle.data.activity.durableJournalEntries).toHaveLength(1); + expect(bundle.data.budgets.ledgers).toHaveLength(1); + expect(bundle.data.budgets.billableCalls).toHaveLength(1); + expect(bundle.data.budgets.events).toHaveLength(1); + expect(bundle.data.sync.connections).toHaveLength(1); + expect(bundle.data.delegation.grants).toHaveLength(1); + expect(bundle.data.delegation.uses).toHaveLength(1); + expect(bundle.data.evidence.captures).toHaveLength(1); + expect(bundle.data.evidence.steps).toHaveLength(1); + expect(bundle.data.activity.shadowComparisons).toHaveLength(1); + expect(bundle.data.outputs.exports).toHaveLength(1); + expect(bundle.data.outputs.publications).toHaveLength(1); + expect(bundle.data.preferenceEvents).toHaveLength(1); + expect(bundle.data.evidence.steps[0]).not.toHaveProperty('screenshotStorageId'); + expect(bundle.data.evidence.steps[0]).not.toHaveProperty('pdfStorageId'); + expect(bundle.data.delegation.grants[0]).not.toHaveProperty('tokenDigest'); + expect(bundle.data.sync.connections[0]).not.toHaveProperty('lastMutationKey'); + expect(bundle.data.outputs.exports[0]).not.toHaveProperty('url'); + expect(serialized).not.toContain('signed-export-capability'); + expect(serialized).not.toContain('iVBORw0KGgo'); + expect(serialized).toContain('[OMITTED_BINARY_DATA]'); + expect(bundle.manifest.collections.map((entry) => entry.path)).toEqual( + expect.arrayContaining([ + 'data.evidence.steps', + 'data.activity.durableSessions', + 'data.budgets.ledgers', + 'data.sync.connections', + 'data.delegation.uses', + 'data.outputs.publications', + ]), + ); + expect(bundle.manifest.completeness.recordCount).toBe( + bundle.manifest.collections.reduce((total, entry) => total + entry.recordCount, 0), + ); + }); + + it('orders records and object keys deterministically', async () => { + const database = new MemoryDatabase(); + seedDeck(database, { deckId: 'deck:ordered', ownerAccessKey: OWNER_ACCESS_KEY }); + database.seed('nodeslide_comments', { + id: 'comment:a', + deckId: 'deck:ordered', + createdAt: 1, + }); + database.seed('nodeslide_comments', { + id: 'comment:z', + deckId: 'deck:ordered', + zeta: 'last key', + alpha: 'first key', + createdAt: 2, + }); + + const first = await exportMyDataHandler(queryContext(database), { + deckId: 'deck:ordered', + ownerAccessKey: OWNER_ACCESS_KEY, + }); + const second = await exportMyDataHandler(queryContext(database), { + deckId: 'deck:ordered', + ownerAccessKey: OWNER_ACCESS_KEY, + }); + + expect(first.data).toEqual(second.data); + expect({ ...first.manifest, generatedAt: 0 }).toEqual({ ...second.manifest, generatedAt: 0 }); + expect(first.data.comments.map((comment) => comment.id)).toEqual(['comment:a', 'comment:z']); + expect(Object.keys(first.data.comments[1])).toEqual([ + 'alpha', + 'createdAt', + 'deckId', + 'id', + 'zeta', + ]); + expect(first.manifest.determinism.objectKeyOrder).toBe('lexicographic'); + }); + it('fails closed when a linked job belongs to another owner capability', async () => { const database = new MemoryDatabase(); seedDeck(database, { deckId: 'deck:owner', ownerAccessKey: OWNER_ACCESS_KEY }); @@ -356,6 +580,97 @@ describe('NodeSlide owner data export', () => { ).rejects.toThrow('failed closed: durable job ownership is inconsistent'); }); + it('fails closed when an evidence step is not bound to an exported capture', async () => { + const database = new MemoryDatabase(); + seedDeck(database, { deckId: 'deck:owner', ownerAccessKey: OWNER_ACCESS_KEY }); + database.seed('nodeslide_evidence_steps', { + id: 'step:orphan', + captureId: 'capture:foreign', + deckId: 'deck:owner', + runId: 'run:owner', + traceId: 'trace:owner', + spanId: 'span:owner', + createdAt: 1, + }); + + await expect( + exportMyDataHandler(queryContext(database), { + deckId: 'deck:owner', + ownerAccessKey: OWNER_ACCESS_KEY, + }), + ).rejects.toThrow('failed closed: evidence step ownership is inconsistent'); + }); + + it('exports a complete immutable claim-to-region custody chain', async () => { + const database = new MemoryDatabase(); + const deckId = 'deck:custody'; + const ownerDigest = `actor_${nodeslideContentDigest(OWNER_ACCESS_KEY)}`; + const revisionDigest = `sha256:${'1'.repeat(64)}`; + const captureDigest = `sha256:${'2'.repeat(64)}`; + const stepDigest = `sha256:${'3'.repeat(64)}`; + const attachmentDigest = `sha256:${'4'.repeat(64)}`; + seedDeck(database, { deckId, ownerAccessKey: OWNER_ACCESS_KEY }); + database.seed('nodeslide_patches', { id: 'patch:custody', deckId, createdAt: 1 }); + database.seed('nodeslide_source_revisions', { + id: `source-revision:${revisionDigest}`, + revisionDigest, + ownerDigest, + deckId, + sourceId: 'source:custody', + contentDigest: `sha256:${'5'.repeat(64)}`, + createdAt: 2, + }); + database.seed('nodeslide_evidence_captures', { + id: 'capture:custody', + deckId, + sourceId: 'source:custody', + sourceRevisionId: `source-revision:${revisionDigest}`, + sourceRevisionDigest: revisionDigest, + captureDigest, + createdAt: 3, + }); + database.seed('nodeslide_evidence_steps', { + id: 'step:custody', + captureId: 'capture:custody', + deckId, + attachmentDigest, + evidenceStepDigest: stepDigest, + createdAt: 4, + }); + database.seed('nodeslide_claim_evidence_receipts', { + id: 'receipt-row:custody', + receiptId: 'receipt:custody', + receiptDigest: `sha256:${'6'.repeat(64)}`, + ownerDigest, + deckId, + patchId: 'patch:custody', + sourceRevisionId: `source-revision:${revisionDigest}`, + sourceRevisionDigest: revisionDigest, + captureId: 'capture:custody', + captureDigest, + evidenceStepId: 'step:custody', + evidenceStepDigest: stepDigest, + attachmentDigest, + createdAt: 5, + }); + + const bundle = await exportMyDataHandler(queryContext(database), { + deckId, + ownerAccessKey: OWNER_ACCESS_KEY, + }); + + expect(bundle.data.sourceRevisions).toHaveLength(1); + expect(bundle.data.evidence.claimReceipts).toHaveLength(1); + expect(bundle.data.sourceRevisions?.[0]).not.toHaveProperty('ownerDigest'); + expect(bundle.data.evidence.claimReceipts?.[0]).not.toHaveProperty('ownerDigest'); + expect(bundle.manifest.collections).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: 'data.sourceRevisions', recordCount: 1 }), + expect.objectContaining({ path: 'data.evidence.claimReceipts', recordCount: 1 }), + ]), + ); + }); + it('fails closed when a version snapshot embeds another deck', async () => { const database = new MemoryDatabase(); seedDeck(database, { deckId: 'deck:owner', ownerAccessKey: OWNER_ACCESS_KEY }); diff --git a/convex/lib/nodeslideDataExport.ts b/convex/lib/nodeslideDataExport.ts index 2330dac..b7940bb 100644 --- a/convex/lib/nodeslideDataExport.ts +++ b/convex/lib/nodeslideDataExport.ts @@ -9,7 +9,7 @@ import { } from '../../shared/nodeslideDataExport'; import type { Doc } from '../_generated/dataModel'; import type { QueryCtx } from '../_generated/server'; -import { nodeslideContentDigest } from './nodeslideIds'; +import { nodeslideContentDigest, nodeslideStableId } from './nodeslideIds'; import { nodeSlideJobOwnerDigest } from './nodeslideJobState'; export const NODESLIDE_DATA_EXPORT_MAX_ROWS_PER_COLLECTION = 1_000; @@ -17,10 +17,56 @@ export const NODESLIDE_DATA_EXPORT_MAX_RECORDS = 8_000; export const NODESLIDE_DATA_EXPORT_MAX_BYTES = 8 * 1024 * 1024; const REDACTED = '[REDACTED]'; +const OMITTED_BINARY_DATA = '[OMITTED_BINARY_DATA]'; const EXCLUDED_COLLECTIONS = [ - { name: 'nodeslide_oauth_sessions', reason: 'authentication_material' as const }, - { name: 'nodeslide_oauth_credentials', reason: 'authentication_material' as const }, - { name: 'nodeslide_presence', reason: 'ephemeral_runtime_state' as const }, + { + name: 'nodeslide_oauth_sessions', + reason: 'authentication_material' as const, + detail: 'OAuth state and PKCE material are never included.', + }, + { + name: 'nodeslide_oauth_credentials', + reason: 'authentication_material' as const, + detail: 'Provider access and refresh credentials are never included.', + }, + { + name: 'nodeslide_presence', + reason: 'ephemeral_runtime_state' as const, + detail: 'Live cursor and presence rows are transient collaboration state.', + }, + { + name: 'nodeslide_rate_limits', + reason: 'infrastructure_state' as const, + detail: 'Shared anti-abuse counters are infrastructure state, not deck content.', + }, + { + name: 'nodeslide_durable_model_result_replays.payloadJson', + reason: 'provider_replay_payload' as const, + detail: 'Provider replay bodies are omitted; journal digests and accounting metadata remain.', + }, + { + name: '_storage and evidence attachment storage ids', + reason: 'binary_blob_contents' as const, + detail: + 'Screenshot, PDF, image, and export blob bytes or storage capabilities are not included.', + }, + { + name: 'nodeslide_signature_profiles', + reason: 'cross_deck_profile' as const, + detail: + 'Tenant-level signature profiles can span decks and are not owner-scoped by this deck capability.', + }, + { + name: 'nodeslide_taste_profiles', + reason: 'cross_deck_profile' as const, + detail: 'Actor taste profiles can span decks and are not owner-scoped by this deck capability.', + }, + { + name: 'nodeslide_deck_ci', + reason: 'computed_not_persisted' as const, + detail: + 'Deck CI is evaluated from exported state; no standalone Deck CI collection currently exists.', + }, ]; type ExportCtx = Pick; @@ -57,6 +103,7 @@ export async function collectNodeSlideOwnerDataExport( comments, versions, sources, + sourceRevisions, jobs, runs, messages, @@ -66,6 +113,16 @@ export async function collectNodeSlideOwnerDataExport( validations, traces, executionTraces, + syncConnections, + delegationGrants, + delegationUses, + evidenceCaptures, + evidenceSteps, + claimEvidenceReceipts, + shadowComparisons, + exports, + publications, + preferenceEvents, ] = await Promise.all([ ctx.db .query('nodeslide_slides') @@ -103,6 +160,10 @@ export async function collectNodeSlideOwnerDataExport( .query('nodeslide_sources') .withIndex('by_deck', (query) => query.eq('deckId', deck.id)) .take(limit), + ctx.db + .query('nodeslide_source_revisions') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(limit), ctx.db .query('nodeslide_agent_jobs') .withIndex('by_result_deck', (query) => query.eq('resultDeckId', deck.id)) @@ -139,8 +200,127 @@ export async function collectNodeSlideOwnerDataExport( .query('nodeslide_execution_traces') .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) .take(limit), + ctx.db + .query('nodeslide_sync_connections') + .withIndex('by_deck_provider', (query) => + query.eq('deckId', deck.id).eq('provider', 'google_slides'), + ) + .take(limit), + ctx.db + .query('nodeslide_delegation_grants') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_delegation_uses') + .withIndex('by_deck_used', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_evidence_steps') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_claim_evidence_receipts') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_shadow_comparisons') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_exports') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_publications') + .withIndex('by_deck_revision', (query) => query.eq('deckId', deck.id)) + .take(limit), + ctx.db + .query('nodeslide_preference_events') + .withIndex('by_deck_recorded', (query) => query.eq('deckId', deck.id)) + .take(limit), ]); + const durableSessionBindings = jobs.map((job) => ({ + job, + sessionId: nodeslideStableId('nsession', job.id), + })); + const durableSessions = ( + await Promise.all( + durableSessionBindings.map(({ sessionId }) => + ctx.db + .query('nodeslide_durable_sessions') + .withIndex('by_stable_id', (query) => query.eq('id', sessionId)) + .take(2), + ), + ) + ).flat(); + const durableSessionEvents = ( + await Promise.all( + durableSessionBindings.map(({ job, sessionId }) => + ctx.db + .query('nodeslide_durable_session_events') + .withIndex('by_session_job', (query) => + query.eq('sessionId', sessionId).eq('jobId', job.id), + ) + .take(limit), + ), + ) + ).flat(); + const durableJournalEntries = ( + await Promise.all( + durableSessionBindings.map(({ job, sessionId }) => + ctx.db + .query('nodeslide_durable_job_journal_entries') + .withIndex('by_binding_sequence', (query) => + query.eq('sessionId', sessionId).eq('jobId', job.id), + ) + .take(limit), + ), + ) + ).flat(); + + const budgetIds = [ + ...new Set( + [...jobs.map((job) => job.budgetId), ...runs.map((run) => run.budgetId)].filter( + (budgetId): budgetId is string => Boolean(budgetId), + ), + ), + ].sort(); + const budgetLedgers = ( + await Promise.all( + budgetIds.map((budgetId) => + ctx.db + .query('nodeslide_run_budgets') + .withIndex('by_stable_id', (query) => query.eq('id', budgetId)) + .take(2), + ), + ) + ).flat(); + const billableCalls = ( + await Promise.all( + budgetIds.map((budgetId) => + ctx.db + .query('nodeslide_billable_calls') + .withIndex('by_budget_status', (query) => query.eq('budgetId', budgetId)) + .take(limit), + ), + ) + ).flat(); + const budgetEvents = ( + await Promise.all( + budgetIds.map((budgetId) => + ctx.db + .query('nodeslide_budget_events') + .withIndex('by_budget_sequence', (query) => query.eq('budgetId', budgetId)) + .take(limit), + ), + ) + ).flat(); + const collections = [ ['slides', slides], ['elements', elements], @@ -151,6 +331,7 @@ export async function collectNodeSlideOwnerDataExport( ['comments', comments], ['versions', versions], ['sources', sources], + ['source revisions', sourceRevisions], ['jobs', jobs], ['runs', runs], ['messages', messages], @@ -160,6 +341,22 @@ export async function collectNodeSlideOwnerDataExport( ['validations', validations], ['traces', traces], ['execution traces', executionTraces], + ['sync connections', syncConnections], + ['delegation grants', delegationGrants], + ['delegation uses', delegationUses], + ['evidence captures', evidenceCaptures], + ['evidence steps', evidenceSteps], + ['claim evidence receipts', claimEvidenceReceipts], + ['shadow comparisons', shadowComparisons], + ['exports', exports], + ['publications', publications], + ['preference events', preferenceEvents], + ['durable sessions', durableSessions], + ['durable session events', durableSessionEvents], + ['durable journal entries', durableJournalEntries], + ['budget ledgers', budgetLedgers], + ['billable calls', billableCalls], + ['budget events', budgetEvents], ] as const; for (const [name, rows] of collections) assertCompleteCollection(name, rows.length); @@ -173,6 +370,7 @@ export async function collectNodeSlideOwnerDataExport( comments, versions, sources, + sourceRevisions, runs, messages, memories, @@ -181,6 +379,16 @@ export async function collectNodeSlideOwnerDataExport( validations, traces, executionTraces, + syncConnections, + delegationGrants, + delegationUses, + evidenceCaptures, + evidenceSteps, + claimEvidenceReceipts, + shadowComparisons, + exports, + publications, + preferenceEvents, ]) { assertDeckScopedRows(deck.id, rows); } @@ -197,14 +405,35 @@ export async function collectNodeSlideOwnerDataExport( throw exportInvariantError('durable run ownership is inconsistent'); } } - for (const version of versions) assertVersionSnapshotScope(version, deck.id); - - const recordCount = 1 + collections.reduce((total, [, rows]) => total + rows.length, 0); - if (recordCount > NODESLIDE_DATA_EXPORT_MAX_RECORDS) { - throw new Error( - `NodeSlide data export exceeds the complete-export limit of ${NODESLIDE_DATA_EXPORT_MAX_RECORDS} records. No partial bundle was returned.`, - ); + for (const revision of sourceRevisions) { + if (revision.ownerDigest !== expectedRunOwnerDigest) { + throw exportInvariantError('source revision ownership is inconsistent'); + } + } + for (const receipt of claimEvidenceReceipts) { + if (receipt.ownerDigest !== expectedRunOwnerDigest) { + throw exportInvariantError('claim evidence receipt ownership is inconsistent'); + } } + for (const version of versions) assertVersionSnapshotScope(version, deck.id); + for (const publication of publications) assertPublishedSnapshotScope(publication, deck.id); + assertLinkedExportRows({ + patches, + jobs, + durableSessions, + durableSessionEvents, + durableJournalEntries, + budgetIds, + budgetLedgers, + billableCalls, + budgetEvents, + delegationGrants, + delegationUses, + evidenceCaptures, + evidenceSteps, + sourceRevisions, + claimEvidenceReceipts, + }); const redaction: RedactionState = { removedFieldCount: 0, @@ -230,20 +459,51 @@ export async function collectNodeSlideOwnerDataExport( variationDecisions: redactRows(variationDecisions, redaction), }, sources: redactRows(sources, redaction), + sourceRevisions: redactRows(sourceRevisions, redaction, ['ownerDigest']), + evidence: { + captures: redactRows(evidenceCaptures, redaction), + steps: redactRows(evidenceSteps, redaction, ['screenshotStorageId', 'pdfStorageId']), + claimReceipts: redactRows(claimEvidenceReceipts, redaction, ['ownerDigest']), + }, memories: redactRows(memories, redaction), activity: { jobs: redactRows(jobs, redaction), + durableSessions: redactRows(durableSessions, redaction), + durableSessionEvents: redactRows(durableSessionEvents, redaction), + durableJournalEntries: redactRows(durableJournalEntries, redaction), runs: redactRows(runs, redaction), messages: redactRows(messages, redaction), spans: redactRows(spans, redaction), events: redactRows(events, redaction), traces: redactRows(traces, redaction), executionTraces: redactRows(executionTraces, redaction), + shadowComparisons: redactRows(shadowComparisons, redaction), validations: redactRows(validations, redaction), }, + budgets: { + ledgers: redactRows(budgetLedgers, redaction), + billableCalls: redactRows(billableCalls, redaction), + events: redactRows(budgetEvents, redaction), + }, + sync: { connections: redactRows(syncConnections, redaction) }, + delegation: { + grants: redactRows(delegationGrants, redaction, ['tokenDigest']), + uses: redactRows(delegationUses, redaction), + }, + outputs: { + exports: redactRows(exports, redaction, ['url']), + publications: redactRows(publications, redaction), + }, + preferenceEvents: redactRows(preferenceEvents, redaction), comments: redactRows(comments, redaction), }; const collectionManifest = dataCollectionManifest(data); + const recordCount = collectionManifest.reduce((total, entry) => total + entry.recordCount, 0); + if (recordCount > NODESLIDE_DATA_EXPORT_MAX_RECORDS) { + throw new Error( + `NodeSlide data export exceeds the complete-export limit of ${NODESLIDE_DATA_EXPORT_MAX_RECORDS} records. No partial bundle was returned.`, + ); + } const bundle: NodeSlideOwnerDataExport = { manifest: { schemaVersion: NODESLIDE_OWNER_DATA_EXPORT_SCHEMA_VERSION, @@ -262,6 +522,12 @@ export async function collectNodeSlideOwnerDataExport( redactedValueCount: redaction.redactedValueCount, excludedCollections: EXCLUDED_COLLECTIONS, }, + determinism: { + collectionOrder: 'schema_defined', + recordOrder: 'creation_time_then_stable_id', + objectKeyOrder: 'lexicographic', + generatedAt: 'request_time_only_nondeterministic_field', + }, retention: { serverCopyCreated: false, bundlePersistence: 'client_download_only', @@ -307,6 +573,126 @@ function assertVersionSnapshotScope(version: Doc<'nodeslide_versions'>, deckId: } } +function assertPublishedSnapshotScope( + publication: Doc<'nodeslide_publications'>, + deckId: string, +): void { + const snapshot = publication.snapshot; + const slideIds = new Set(snapshot.slides.map((slide) => slide.id)); + if ( + snapshot.deck.id !== deckId || + snapshot.slides.some((slide) => slide.deckId !== deckId) || + snapshot.sources.some((source) => source.deckId !== deckId) || + snapshot.elements.some((element) => !slideIds.has(element.slideId)) + ) { + throw exportInvariantError('a publication snapshot crossed the authorized deck boundary'); + } +} + +function assertLinkedExportRows(args: { + patches: readonly Doc<'nodeslide_patches'>[]; + jobs: readonly Doc<'nodeslide_agent_jobs'>[]; + durableSessions: readonly Doc<'nodeslide_durable_sessions'>[]; + durableSessionEvents: readonly Doc<'nodeslide_durable_session_events'>[]; + durableJournalEntries: readonly Doc<'nodeslide_durable_job_journal_entries'>[]; + budgetIds: readonly string[]; + budgetLedgers: readonly Doc<'nodeslide_run_budgets'>[]; + billableCalls: readonly Doc<'nodeslide_billable_calls'>[]; + budgetEvents: readonly Doc<'nodeslide_budget_events'>[]; + delegationGrants: readonly Doc<'nodeslide_delegation_grants'>[]; + delegationUses: readonly Doc<'nodeslide_delegation_uses'>[]; + evidenceCaptures: readonly Doc<'nodeslide_evidence_captures'>[]; + evidenceSteps: readonly Doc<'nodeslide_evidence_steps'>[]; + sourceRevisions: readonly Doc<'nodeslide_source_revisions'>[]; + claimEvidenceReceipts: readonly Doc<'nodeslide_claim_evidence_receipts'>[]; +}): void { + const jobsById = new Map(args.jobs.map((job) => [job.id, job])); + const sessionToJob = new Map( + args.jobs.map((job) => [nodeslideStableId('nsession', job.id), job] as const), + ); + if ( + new Set(args.durableSessions.map((session) => session.id)).size !== args.durableSessions.length + ) { + throw exportInvariantError('duplicate durable sessions were resolved'); + } + for (const session of args.durableSessions) { + const job = sessionToJob.get(session.id); + if ( + !job || + session.requestDigest !== job.requestDigest || + session.jobs[job.id]?.requestBinding.requestDigest !== job.requestDigest + ) { + throw exportInvariantError('durable session binding is inconsistent'); + } + } + for (const row of [...args.durableSessionEvents, ...args.durableJournalEntries]) { + const job = sessionToJob.get(row.sessionId); + if (!job || row.jobId !== job.id || !jobsById.has(row.jobId)) { + throw exportInvariantError('durable session child ownership is inconsistent'); + } + } + + const allowedBudgetIds = new Set(args.budgetIds); + if ( + args.budgetLedgers.length !== allowedBudgetIds.size || + args.budgetLedgers.some((budget) => !allowedBudgetIds.has(budget.id)) + ) { + throw exportInvariantError('budget ledger binding is inconsistent'); + } + for (const row of [...args.billableCalls, ...args.budgetEvents]) { + if (!allowedBudgetIds.has(row.budgetId)) { + throw exportInvariantError('budget child ownership is inconsistent'); + } + } + + const grantIds = new Set(args.delegationGrants.map((grant) => grant.id)); + if (args.delegationUses.some((use) => !grantIds.has(use.grantId))) { + throw exportInvariantError('delegation receipt ownership is inconsistent'); + } + const captureIds = new Set(args.evidenceCaptures.map((capture) => capture.id)); + if (args.evidenceSteps.some((step) => !captureIds.has(step.captureId))) { + throw exportInvariantError('evidence step ownership is inconsistent'); + } + const revisionsById = new Map(args.sourceRevisions.map((revision) => [revision.id, revision])); + for (const revision of args.sourceRevisions) { + if ( + revision.predecessorRevisionId !== undefined && + (revisionsById.get(revision.predecessorRevisionId)?.revisionDigest !== + revision.predecessorRevisionDigest || + revision.predecessorRevisionId === revision.id) + ) { + throw exportInvariantError('source revision predecessor binding is inconsistent'); + } + } + for (const capture of args.evidenceCaptures) { + if ( + capture.sourceRevisionId !== undefined && + revisionsById.get(capture.sourceRevisionId)?.revisionDigest !== capture.sourceRevisionDigest + ) { + throw exportInvariantError('evidence capture source revision binding is inconsistent'); + } + } + const patchesById = new Set(args.patches.map((patch) => patch.id)); + const stepsById = new Map(args.evidenceSteps.map((step) => [step.id, step])); + const capturesById = new Map(args.evidenceCaptures.map((capture) => [capture.id, capture])); + for (const receipt of args.claimEvidenceReceipts) { + const revision = revisionsById.get(receipt.sourceRevisionId); + const capture = capturesById.get(receipt.captureId); + const step = stepsById.get(receipt.evidenceStepId); + if ( + !patchesById.has(receipt.patchId) || + revision?.revisionDigest !== receipt.sourceRevisionDigest || + capture?.captureDigest !== receipt.captureDigest || + capture?.sourceRevisionId !== receipt.sourceRevisionId || + step?.captureId !== receipt.captureId || + step?.evidenceStepDigest !== receipt.evidenceStepDigest || + step?.attachmentDigest !== receipt.attachmentDigest + ) { + throw exportInvariantError('claim evidence receipt custody binding is inconsistent'); + } + } +} + function exportInvariantError(detail: string): Error { return new Error(`NodeSlide data export failed closed: ${detail}.`); } @@ -314,44 +700,56 @@ function exportInvariantError(detail: string): Error { function redactRows( rows: readonly T[], state: RedactionState, + omittedFields: readonly string[] = [], ): NodeSlideDataExportRecord[] { + const omissions = new Set(omittedFields); return [...rows] .sort( (left, right) => left._creationTime - right._creationTime || String(left.id ?? left._id).localeCompare(String(right.id ?? right._id)), ) - .map((row) => redactRecord(row, state)); + .map((row) => redactRecord(row, state, omissions)); } -function redactRecord(value: object, state: RedactionState): NodeSlideDataExportRecord { - const redacted = redactValue(value, state); +function redactRecord( + value: object, + state: RedactionState, + omittedFields: ReadonlySet = new Set(), +): NodeSlideDataExportRecord { + const redacted = redactValue(value, state, omittedFields); if (!redacted || Array.isArray(redacted) || typeof redacted !== 'object') { throw exportInvariantError('a persisted record could not be serialized'); } return redacted; } -function redactValue(value: unknown, state: RedactionState): NodeSlideDataExportValue | undefined { +function redactValue( + value: unknown, + state: RedactionState, + omittedFields: ReadonlySet, +): NodeSlideDataExportValue | undefined { if (value === null || typeof value === 'boolean') return value; if (typeof value === 'number') return Number.isFinite(value) ? value : null; if (typeof value === 'string') return redactString(value, state); if (typeof value === 'bigint') return value.toString(); if (Array.isArray(value)) { return value.flatMap((item) => { - const redacted = redactValue(item, state); + const redacted = redactValue(item, state, omittedFields); return redacted === undefined ? [] : [redacted]; }); } if (typeof value !== 'object' || value === undefined) return undefined; const record: NodeSlideDataExportRecord = {}; - for (const [key, item] of Object.entries(value)) { + for (const [key, item] of Object.entries(value).sort(([left], [right]) => + left.localeCompare(right), + )) { if (key === '_id' || key === '_creationTime' || item === undefined) continue; - if (isSensitiveField(key)) { + if (omittedFields.has(key) || isSensitiveField(key)) { state.removedFieldCount += 1; continue; } - const redacted = redactValue(item, state); + const redacted = redactValue(item, state, omittedFields); if (redacted !== undefined) record[key] = redacted; } return record; @@ -375,6 +773,8 @@ function isSensitiveField(key: string): boolean { 'cookie', 'setcookie', 'providerconsent', + 'clientsessionid', + 'lastmutationkey', ].includes(normalized) ) { return true; @@ -391,6 +791,7 @@ function isSensitiveField(key: string): boolean { ) { return true; } + if (normalized.endsWith('storageid')) return true; if ( /^(?:owner|share|read|write|admin|session)?capability(?:key|token|value)?$/.test(normalized) ) { @@ -402,6 +803,10 @@ function isSensitiveField(key: string): boolean { } function redactString(value: string, state: RedactionState): string { + if (/^data:(?:image\/[^;,]+|application\/pdf);base64,/i.test(value)) { + state.redactedValueCount += 1; + return OMITTED_BINARY_DATA; + } let redacted = value; for (const sensitiveValue of state.sensitiveValues) { redacted = redacted.split(sensitiveValue).join(REDACTED); @@ -442,8 +847,27 @@ function dataCollectionManifest( recordCount: data.proposals.variationDecisions.length, }, { path: 'data.sources', recordCount: data.sources.length }, + { path: 'data.sourceRevisions', recordCount: data.sourceRevisions?.length ?? 0 }, + { path: 'data.evidence.captures', recordCount: data.evidence.captures.length }, + { path: 'data.evidence.steps', recordCount: data.evidence.steps.length }, + { + path: 'data.evidence.claimReceipts', + recordCount: data.evidence.claimReceipts?.length ?? 0, + }, { path: 'data.memories', recordCount: data.memories.length }, { path: 'data.activity.jobs', recordCount: data.activity.jobs.length }, + { + path: 'data.activity.durableSessions', + recordCount: data.activity.durableSessions.length, + }, + { + path: 'data.activity.durableSessionEvents', + recordCount: data.activity.durableSessionEvents.length, + }, + { + path: 'data.activity.durableJournalEntries', + recordCount: data.activity.durableJournalEntries.length, + }, { path: 'data.activity.runs', recordCount: data.activity.runs.length }, { path: 'data.activity.messages', recordCount: data.activity.messages.length }, { path: 'data.activity.spans', recordCount: data.activity.spans.length }, @@ -453,7 +877,20 @@ function dataCollectionManifest( path: 'data.activity.executionTraces', recordCount: data.activity.executionTraces.length, }, + { + path: 'data.activity.shadowComparisons', + recordCount: data.activity.shadowComparisons.length, + }, { path: 'data.activity.validations', recordCount: data.activity.validations.length }, + { path: 'data.budgets.ledgers', recordCount: data.budgets.ledgers.length }, + { path: 'data.budgets.billableCalls', recordCount: data.budgets.billableCalls.length }, + { path: 'data.budgets.events', recordCount: data.budgets.events.length }, + { path: 'data.sync.connections', recordCount: data.sync.connections.length }, + { path: 'data.delegation.grants', recordCount: data.delegation.grants.length }, + { path: 'data.delegation.uses', recordCount: data.delegation.uses.length }, + { path: 'data.outputs.exports', recordCount: data.outputs.exports.length }, + { path: 'data.outputs.publications', recordCount: data.outputs.publications.length }, + { path: 'data.preferenceEvents', recordCount: data.preferenceEvents.length }, { path: 'data.comments', recordCount: data.comments.length }, ]; } diff --git a/convex/lib/nodeslideDeckCi.test.ts b/convex/lib/nodeslideDeckCi.test.ts new file mode 100644 index 0000000..7fc4a7c --- /dev/null +++ b/convex/lib/nodeslideDeckCi.test.ts @@ -0,0 +1,395 @@ +import { describe, expect, it } from 'vitest'; +import { + type DeckSnapshot, + NODESLIDE_SCHEMA_VERSION, + NODESLIDE_TOOLCHAIN_VERSION, + type PatchOperation, + type PatchScope, + type Slide, + type SlideElement, + type SourceRecord, +} from '../../shared/nodeslide'; +import { + evaluateNodeSlideSemanticCoverage, + materializeNodeSlideCandidate, +} from './nodeslideCandidate'; +import { evaluateNodeSlideDeckCi, nodeSlideDeckCiAllowsAutoCommit } from './nodeslideDeckCi'; + +const NOW = 1_800_000_000_000; +const STALE_AFTER_MS = 10_000; + +describe('NodeSlide Deck CI', () => { + it('passes a validation-clean deck with fresh, ready, source-bound evidence', () => { + const result = evaluateNodeSlideDeckCi(snapshot(), options()); + + expect(result.status).toBe('pass'); + expect(result.checks).toEqual([]); + expect(result.blockerCount).toBe(0); + expect(result.severityCounts).toEqual({ critical: 0, error: 0, warning: 0, info: 0 }); + expect(result.digest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(result.validation).toMatchObject({ ok: true, publishOk: true, cleanOk: true }); + expect(nodeSlideDeckCiAllowsAutoCommit(result)).toBe(true); + }); + + it('fails closed on stale, failed, unsupported, and missing evidence plus hook blockers', () => { + const candidate = snapshot(); + const secondary = requiredSource(candidate, 'source-secondary'); + secondary.status = 'failed'; + secondary.retrievedAt = NOW - STALE_AFTER_MS - 1; + const claim = requiredElement(candidate, 'opening-copy'); + claim.content = 'Revenue reached 10 million in 2025.'; + claim.role = 'metric'; + claim.sourceIds = []; + const chart = requiredElement(candidate, 'evidence-chart'); + if (!chart.chart) throw new Error('Expected chart fixture.'); + chart.sourceIds = ['source-missing']; + chart.chart.sourceId = 'source-missing'; + + const result = evaluateNodeSlideDeckCi(candidate, { + ...options(), + layoutStructureChecks: [ + { + code: 'render_overlap', + status: 'fail', + message: 'Rendered labels overlap.', + slideIds: ['slide-evidence'], + elementIds: ['evidence-chart'], + }, + ], + exportReadinessChecks: [ + { + code: 'pptx_fallback_missing', + status: 'fail', + message: 'The PPTX renderer has no editable fallback.', + slideIds: ['slide-close'], + }, + ], + }); + + expect(result.status).toBe('fail'); + expect(result.blockerCount).toBeGreaterThanOrEqual(5); + expect(codes(result)).toEqual( + expect.arrayContaining([ + 'source_stale', + 'source_failed', + 'unsupported_consequential_claim', + 'source_reference_missing', + 'render_overlap', + 'pptx_fallback_missing', + ]), + ); + expect(result.affectedSlideIds).toEqual( + expect.arrayContaining(['slide-opening', 'slide-evidence', 'slide-proof', 'slide-close']), + ); + expect(result.affectedElementIds).toEqual( + expect.arrayContaining(['opening-copy', 'evidence-chart', 'proof-formula']), + ); + expect(result.affectedSourceIds).toEqual( + expect.arrayContaining(['source-secondary', 'source-missing']), + ); + expect(nodeSlideDeckCiAllowsAutoCommit(result)).toBe(false); + }); + + it('returns warnings without blockers for refreshing evidence and advisory hooks', () => { + const candidate = snapshot(); + requiredSource(candidate, 'source-primary').status = 'refreshing'; + + const result = evaluateNodeSlideDeckCi(candidate, { + ...options(), + exportReadinessChecks: [ + { + code: 'pdf_font_substitution', + status: 'warning', + message: 'The PDF renderer substituted one font.', + slideIds: ['slide-evidence'], + }, + ], + }); + + expect(result.status).toBe('warn'); + expect(result.blockerCount).toBe(0); + expect(result.severityCounts.warning).toBe(2); + expect(codes(result)).toEqual( + expect.arrayContaining(['source_refreshing', 'pdf_font_substitution']), + ); + expect(nodeSlideDeckCiAllowsAutoCommit(result)).toBe(false); + }); + + it('fails the Turbo gate closed when a receipt is internally inconsistent', () => { + const clean = evaluateNodeSlideDeckCi(snapshot(), options()); + + expect(nodeSlideDeckCiAllowsAutoCommit({ ...clean, blockerCount: 1 })).toBe(false); + expect( + nodeSlideDeckCiAllowsAutoCommit({ + ...clean, + validation: { ...clean.validation, cleanOk: false }, + }), + ).toBe(false); + }); + + it('fails Deck CI and Turbo for an under-covered candidate bound to an exact receipt', () => { + const base = snapshot(); + const target = requiredElement(base, 'opening-copy'); + const scope: PatchScope = { + kind: 'slide', + deckId: base.deck.id, + slideIds: ['slide-opening'], + operationMode: 'unrestricted', + }; + const operations: PatchOperation[] = [ + { + op: 'move', + slideId: target.slideId, + elementId: target.id, + x: target.bbox.x + 0.01, + y: target.bbox.y, + }, + ]; + const semanticCoverage = evaluateNodeSlideSemanticCoverage({ + snapshot: base, + instruction: 'Rewrite the headline and body copy on slide 1.', + scope, + operations, + focusSlideId: 'slide-opening', + }); + const candidate = materializeNodeSlideCandidate(base, { scope, operations }, NOW); + + const result = evaluateNodeSlideDeckCi(candidate, { + ...options(), + semanticCoverage, + }); + + expect(semanticCoverage.status).toBe('blocked'); + expect(result.status).toBe('fail'); + expect(codes(result)).toContain('semantic_coverage_undercovered'); + expect(nodeSlideDeckCiAllowsAutoCommit(result)).toBe(false); + }); + + it('fails Deck CI closed when semantic coverage belongs to a different candidate', () => { + const base = snapshot(); + const target = requiredElement(base, 'opening-copy'); + const scope: PatchScope = { + kind: 'elements', + deckId: base.deck.id, + slideIds: [target.slideId], + elementIds: [target.id], + operationMode: 'copy', + }; + const operations: PatchOperation[] = [ + { + op: 'replace_text', + slideId: target.slideId, + elementId: target.id, + text: 'Updated opening copy.', + }, + ]; + const receipt = evaluateNodeSlideSemanticCoverage({ + snapshot: base, + instruction: 'Rewrite the selected text.', + scope, + operations, + }); + + const result = evaluateNodeSlideDeckCi(base, { ...options(), semanticCoverage: receipt }); + + expect(result.status).toBe('fail'); + expect(codes(result)).toContain('semantic_coverage_receipt_mismatch'); + expect(nodeSlideDeckCiAllowsAutoCommit(result)).toBe(false); + }); + + it('produces a deterministic digest and does not mutate its inputs', () => { + const candidate = snapshot(); + const before = structuredClone(candidate); + const first = evaluateNodeSlideDeckCi(candidate, options()); + const second = evaluateNodeSlideDeckCi(structuredClone(candidate), options()); + + expect(first).toEqual(second); + expect(first.digest).toBe(second.digest); + expect(candidate).toEqual(before); + }); + + it('scopes a changed source to only the slides and elements bound to that source', () => { + const result = evaluateNodeSlideDeckCi(snapshot(), { + ...options(), + changedSourceIds: ['source-primary'], + }); + + expect(result.status).toBe('pass'); + expect(result.changedSourceImpact).toEqual({ + changedSourceIds: ['source-primary'], + boundSourceIds: ['source-primary'], + unboundSourceIds: [], + missingSourceIds: [], + slideIds: ['slide-evidence'], + elementIds: ['evidence-chart'], + }); + expect(result.changedSourceImpact.slideIds).not.toContain('slide-proof'); + expect(result.changedSourceImpact.slideIds).not.toContain('slide-close'); + }); +}); + +function options() { + return { + referenceTime: NOW, + semantic: { sourceStaleAfterMs: STALE_AFTER_MS }, + } as const; +} + +function snapshot(): DeckSnapshot { + const slides: Slide[] = [ + slide('slide-opening', 'Opening decision', 'Opening / 01', 'Narrative opening.'), + slide('slide-evidence', 'Measured evidence', 'Evidence / 02', 'Source: official dataset.'), + slide('slide-proof', 'Transparent proof', 'Proof / 03', 'Source: calculation inputs.'), + slide('slide-close', 'Reviewable close', 'Close / 04', 'Narrative close.'), + ]; + const elements: SlideElement[] = [ + text('opening-copy', 'slide-opening', 'A reversible decision opens the story.'), + chart('evidence-chart', 'slide-evidence'), + math('proof-formula', 'slide-proof'), + text('close-copy', 'slide-close', 'The audience retains an editable next step.'), + ]; + for (const item of slides) { + item.elementOrder = elements + .filter((element) => element.slideId === item.id) + .map((element) => element.id); + } + return { + deck: { + schemaVersion: NODESLIDE_SCHEMA_VERSION, + toolchainVersion: NODESLIDE_TOOLCHAIN_VERSION, + id: 'deck-ci-test', + projectId: 'project-ci-test', + title: 'Deck CI proof', + brief: { + prompt: 'Build a reviewable evidence story.', + audience: 'Reviewers', + purpose: 'Exercise Deck CI', + successCriteria: ['Keep evidence source-bound'], + }, + theme: { + id: 'ci-theme', + name: 'CI theme', + mode: 'light', + colors: { + canvas: '#ffffff', + ink: '#111111', + muted: '#555555', + accent: '#3355aa', + accentSoft: '#eef2ff', + insight: '#eef8f1', + insightInk: '#16442d', + trace: '#6655cc', + border: '#dddddd', + }, + typography: { display: 'serif', body: 'sans-serif', data: 'monospace' }, + defaultRadius: 8, + spacingUnit: 8, + }, + slideOrder: slides.map((item) => item.id), + version: 1, + status: 'draft', + createdAt: NOW - 1_000, + updatedAt: NOW - 100, + }, + slides, + elements, + sources: [ + source('source-primary', 'Official dataset'), + source('source-secondary', 'Calculation inputs'), + ], + }; +} + +function slide(id: string, title: string, section: string, notes: string): Slide { + return { + id, + deckId: 'deck-ci-test', + title, + section, + notes, + background: '#ffffff', + elementOrder: [], + version: 1, + }; +} + +function baseElement(id: string, slideId: string): Omit { + return { + id, + slideId, + name: id, + bbox: { x: 0.1, y: 0.15, width: 0.8, height: 0.6 }, + rotation: 0, + style: { color: '#111111', fontSize: 20 }, + sourceIds: [], + locked: false, + exportCapabilities: ['web_native', 'pptx_editable', 'google_importable'], + version: 1, + }; +} + +function text(id: string, slideId: string, content: string): SlideElement { + return { ...baseElement(id, slideId), kind: 'text', content }; +} + +function chart(id: string, slideId: string): SlideElement { + return { + ...baseElement(id, slideId), + kind: 'chart', + sourceIds: ['source-primary'], + chart: { + chartType: 'bar', + labels: ['2024', '2025'], + series: [{ name: 'Revenue', values: [8, 10] }], + unit: 'million USD', + sourceId: 'source-primary', + }, + }; +} + +function math(id: string, slideId: string): SlideElement { + return { + ...baseElement(id, slideId), + kind: 'math', + content: '10 / 2 = 5', + sourceIds: ['source-secondary'], + math: { + expression: 'revenue / customers', + display: '10 / 2 = 5', + variables: [ + { label: 'revenue', value: 10 }, + { label: 'customers', value: 2 }, + ], + sourceId: 'source-secondary', + }, + }; +} + +function source(id: string, title: string): SourceRecord { + return { + id, + deckId: 'deck-ci-test', + title, + url: `https://example.test/${id}`, + sourceType: 'url', + retrievedAt: NOW - 100, + citation: `${title}, official publication.`, + status: 'ready', + }; +} + +function requiredSource(candidate: DeckSnapshot, id: string): SourceRecord { + const item = candidate.sources.find((source) => source.id === id); + if (!item) throw new Error(`Expected source ${id}.`); + return item; +} + +function requiredElement(candidate: DeckSnapshot, id: string): SlideElement { + const item = candidate.elements.find((element) => element.id === id); + if (!item) throw new Error(`Expected element ${id}.`); + return item; +} + +function codes(result: ReturnType): string[] { + return result.checks.map((check) => check.code); +} diff --git a/convex/lib/nodeslideDeckCi.ts b/convex/lib/nodeslideDeckCi.ts new file mode 100644 index 0000000..acefb88 --- /dev/null +++ b/convex/lib/nodeslideDeckCi.ts @@ -0,0 +1,699 @@ +import type { + DeckSnapshot, + SlideElement, + ValidationIssue, + ValidationResult, +} from '../../shared/nodeslide'; +import { NODESLIDE_TOOLCHAIN_VERSION } from '../../shared/nodeslide'; +import { + type NodeSlidePresentationQualityReceipt, + verifyNodeSlidePresentationQualityReceipt, +} from '../../shared/nodeslideAuthoringQuality'; +import { + type NodeSlideSemanticCoverageReceipt, + nodeSlideCandidateDigest, + nodeSlideSemanticCoverageReceiptMatches, +} from './nodeslideCandidate'; +import { nodeslideContentDigest, nodeslideStableId } from './nodeslideIds'; +import { + type NodeSlideSemanticEvaluationOptions, + type NodeSlideSemanticFinding, + evaluateNodeSlideSemantics, +} from './nodeslideSemanticEvaluation'; +import { validateNodeSlideSnapshot } from './nodeslideValidation'; + +export const NODESLIDE_DECK_CI_SCHEMA_VERSION = 'nodeslide.deck-ci/v1' as const; + +export type NodeSlideDeckCiStatus = 'pass' | 'warn' | 'fail'; +export type NodeSlideDeckCiSeverity = 'critical' | 'error' | 'warning' | 'info'; +export type NodeSlideDeckCiCategory = + | 'evidence' + | 'claims' + | 'source_binding' + | 'layout' + | 'structure' + | 'export'; +export type NodeSlideDeckCiOrigin = + | 'semantic' + | 'presentation_quality' + | 'validation' + | 'layout_structure_hook' + | 'export_readiness_hook'; + +export interface NodeSlideDeckCiCheck { + id: string; + code: string; + category: NodeSlideDeckCiCategory; + origin: NodeSlideDeckCiOrigin; + severity: NodeSlideDeckCiSeverity; + blocker: boolean; + message: string; + slideIds: string[]; + elementIds: string[]; + sourceIds: string[]; +} + +/** + * Result supplied by a pure layout/structure or export adapter. Passing adapter output rather + * than an I/O callback keeps Deck CI replayable in tests, workers, and durable jobs. + */ +export interface NodeSlideDeckCiHookCheckInput { + code: string; + status: 'pass' | 'warning' | 'fail'; + message: string; + /** Failed hook checks block by default. Warnings and passes do not. */ + blocker?: boolean; + slideIds?: readonly string[]; + elementIds?: readonly string[]; + sourceIds?: readonly string[]; +} + +export interface NodeSlideDeckCiOptions { + /** Defaults to the persisted deck update time; wall-clock time is never read. */ + referenceTime?: number; + /** Optional authoritative receipt. Stale/mismatched receipts fail closed and are recomputed. */ + validation?: ValidationResult; + semantic?: Omit; + /** Pure integration input for render-aware layout and structure checks. */ + layoutStructureChecks?: readonly NodeSlideDeckCiHookCheckInput[]; + /** Pure integration input for renderer- or format-specific export checks. */ + exportReadinessChecks?: readonly NodeSlideDeckCiHookCheckInput[]; + /** Source revisions in the triggering change; used only to calculate dependency impact. */ + changedSourceIds?: readonly string[]; + /** Exact planner-intent coverage for this materialized candidate. */ + semanticCoverage?: NodeSlideSemanticCoverageReceipt; + /** Optional release-quality receipt. When supplied it must bind to this exact deck version. */ + presentationQuality?: NodeSlidePresentationQualityReceipt; +} + +export interface NodeSlideDeckCiInput extends NodeSlideDeckCiOptions { + snapshot: DeckSnapshot; +} + +export interface NodeSlideDeckCiResult { + schemaVersion: typeof NODESLIDE_DECK_CI_SCHEMA_VERSION; + deckId: string; + deckVersion: number; + snapshotDigest: string; + referenceTime: number; + status: NodeSlideDeckCiStatus; + checks: NodeSlideDeckCiCheck[]; + blockerCount: number; + severityCounts: Record; + affectedSlideIds: string[]; + affectedElementIds: string[]; + affectedSourceIds: string[]; + changedSourceImpact: { + changedSourceIds: string[]; + boundSourceIds: string[]; + unboundSourceIds: string[]; + missingSourceIds: string[]; + slideIds: string[]; + elementIds: string[]; + }; + validation: { + id: string; + supplied: boolean; + inputAccepted: boolean; + ok: boolean; + publishOk: boolean; + cleanOk: boolean; + }; + semantic: { + id: string; + verdict: 'pass' | 'advisory' | 'blocked'; + }; + presentationQuality?: { + digest: string; + status: NodeSlidePresentationQualityReceipt['status']; + overall: number; + blockerCount: number; + }; + digest: string; +} + +/** + * Turbo is deliberately stricter than publication readiness alone. A candidate + * may auto-commit only when the complete deterministic Deck CI receipt is a + * clean pass for the exact materialized snapshot. Warnings remain reviewable. + */ +export function nodeSlideDeckCiAllowsAutoCommit( + result: Pick, +): boolean { + return ( + result.status === 'pass' && + result.blockerCount === 0 && + result.validation.ok && + result.validation.publishOk && + result.validation.cleanOk + ); +} + +const SEMANTIC_CODES = new Set([ + 'factual_claim_unbound', + 'source_evidence_incomplete', + 'source_failed', + 'source_missing', + 'source_reference_missing', + 'source_refreshing', + 'source_stale', + 'source_timestamp_invalid', +]); + +const SEVERITY_RANK: Record = { + critical: 0, + error: 1, + warning: 2, + info: 3, +}; + +const CATEGORY_RANK: Record = { + evidence: 0, + claims: 1, + source_binding: 2, + structure: 3, + layout: 4, + export: 5, +}; + +export function evaluateNodeSlideDeckCi(input: NodeSlideDeckCiInput): NodeSlideDeckCiResult; +export function evaluateNodeSlideDeckCi( + snapshot: DeckSnapshot, + options?: NodeSlideDeckCiOptions, +): NodeSlideDeckCiResult; +export function evaluateNodeSlideDeckCi( + input: NodeSlideDeckCiInput | DeckSnapshot, + options: NodeSlideDeckCiOptions = {}, +): NodeSlideDeckCiResult { + const { snapshot, resolvedOptions } = normalizeInput(input, options); + const referenceTime = resolvedReferenceTime(snapshot, resolvedOptions.referenceTime); + const snapshotDigest = nodeSlideCandidateDigest(snapshot); + const validationInputAccepted = validationMatchesSnapshot(snapshot, resolvedOptions.validation); + const validation = validationInputAccepted + ? (resolvedOptions.validation as ValidationResult) + : validateNodeSlideSnapshot(snapshot, referenceTime); + const semantic = evaluateNodeSlideSemantics( + { kind: 'snapshot', snapshot }, + { + ...resolvedOptions.semantic, + referenceTime, + }, + ); + + const drafts: CheckDraft[] = []; + if (resolvedOptions.validation && !validationInputAccepted) { + drafts.push({ + code: 'validation_input_mismatch', + category: 'structure', + origin: 'validation', + severity: 'error', + blocker: true, + message: + 'The supplied validation receipt does not match this exact deck version and toolchain.', + slideIds: [], + elementIds: [], + sourceIds: [], + }); + } + if (resolvedOptions.semanticCoverage) { + drafts.push(...checksFromSemanticCoverage(snapshot, resolvedOptions.semanticCoverage)); + } + if (resolvedOptions.presentationQuality) { + drafts.push(...checksFromPresentationQuality(snapshot, resolvedOptions.presentationQuality)); + } + for (const finding of semantic.findings) { + if (SEMANTIC_CODES.has(finding.code)) drafts.push(checkFromSemanticFinding(finding)); + } + drafts.push(...checksFromValidation(snapshot, validation)); + drafts.push( + ...checksFromHookInputs( + 'layout_structure_hook', + resolvedOptions.layoutStructureChecks, + 'layout', + ), + ...checksFromHookInputs( + 'export_readiness_hook', + resolvedOptions.exportReadinessChecks, + 'export', + ), + ); + + const checks = materializeChecks(snapshot, drafts); + const severityCounts = countSeverities(checks); + const blockerCount = checks.filter((check) => check.blocker).length; + const status: NodeSlideDeckCiStatus = + blockerCount > 0 ? 'fail' : severityCounts.error + severityCounts.warning > 0 ? 'warn' : 'pass'; + const affectedSlideIds = orderedSlideIds( + snapshot, + checks.flatMap((check) => check.slideIds), + ); + const affectedElementIds = orderedElementIds( + snapshot, + checks.flatMap((check) => check.elementIds), + ); + const affectedSourceIds = uniqueSorted(checks.flatMap((check) => check.sourceIds)); + const changedSourceImpact = sourceChangeImpact(snapshot, resolvedOptions.changedSourceIds ?? []); + + const partial = { + schemaVersion: NODESLIDE_DECK_CI_SCHEMA_VERSION, + deckId: snapshot.deck.id, + deckVersion: snapshot.deck.version, + snapshotDigest, + referenceTime, + status, + checks, + blockerCount, + severityCounts, + affectedSlideIds, + affectedElementIds, + affectedSourceIds, + changedSourceImpact, + validation: { + id: validation.id, + supplied: resolvedOptions.validation !== undefined, + inputAccepted: validationInputAccepted, + ok: validation.ok, + publishOk: validation.publishOk, + cleanOk: validation.cleanOk, + }, + semantic: { id: semantic.id, verdict: semantic.verdict }, + ...(resolvedOptions.presentationQuality + ? { + presentationQuality: { + digest: resolvedOptions.presentationQuality.digest, + status: resolvedOptions.presentationQuality.status, + overall: resolvedOptions.presentationQuality.scores.overall, + blockerCount: resolvedOptions.presentationQuality.blockerCount, + }, + } + : {}), + }; + return { ...partial, digest: nodeslideContentDigest(stableSerialize(partial)) }; +} + +interface CheckDraft extends Omit {} + +function normalizeInput( + input: NodeSlideDeckCiInput | DeckSnapshot, + options: NodeSlideDeckCiOptions, +): { snapshot: DeckSnapshot; resolvedOptions: NodeSlideDeckCiOptions } { + if ('snapshot' in input) { + const { snapshot, ...embeddedOptions } = input; + return { snapshot, resolvedOptions: embeddedOptions }; + } + return { snapshot: input, resolvedOptions: options }; +} + +function resolvedReferenceTime(snapshot: DeckSnapshot, value: number | undefined): number { + const referenceTime = value ?? snapshot.deck.updatedAt; + if (!Number.isSafeInteger(referenceTime) || referenceTime < 0) { + throw new Error('Deck CI referenceTime must be a non-negative safe integer.'); + } + return referenceTime; +} + +function validationMatchesSnapshot( + snapshot: DeckSnapshot, + validation: ValidationResult | undefined, +): validation is ValidationResult { + return Boolean( + validation && + validation.deckId === snapshot.deck.id && + validation.deckVersion === snapshot.deck.version && + validation.toolchainVersion === snapshot.deck.toolchainVersion && + validation.toolchainVersion === NODESLIDE_TOOLCHAIN_VERSION, + ); +} + +function checkFromSemanticFinding(finding: NodeSlideSemanticFinding): CheckDraft { + return { + code: + finding.code === 'factual_claim_unbound' + ? 'unsupported_consequential_claim' + : finding.code === 'source_missing' + ? 'source_reference_missing' + : finding.code, + category: + finding.code === 'factual_claim_unbound' + ? 'claims' + : finding.code === 'source_missing' || finding.code === 'source_reference_missing' + ? 'source_binding' + : 'evidence', + origin: 'semantic', + severity: finding.severity, + blocker: finding.disposition === 'hard_blocker', + message: finding.message, + slideIds: finding.bindings.slideIds, + elementIds: finding.bindings.elementIds, + sourceIds: finding.bindings.sourceIds, + }; +} + +function checksFromSemanticCoverage( + snapshot: DeckSnapshot, + receipt: NodeSlideSemanticCoverageReceipt, +): CheckDraft[] { + if (!nodeSlideSemanticCoverageReceiptMatches(receipt, snapshot)) { + return [ + { + code: 'semantic_coverage_receipt_mismatch', + category: 'claims', + origin: 'semantic', + severity: 'critical', + blocker: true, + message: 'The semantic-coverage receipt is invalid or belongs to a different candidate.', + slideIds: [], + elementIds: [], + sourceIds: [], + }, + ]; + } + if (receipt.status !== 'blocked') return []; + const missing = new Set(receipt.missingObligationIds); + const obligations = receipt.obligations.filter((obligation) => missing.has(obligation.id)); + return [ + { + code: 'semantic_coverage_undercovered', + category: 'claims', + origin: 'semantic', + severity: 'critical', + blocker: true, + message: `The candidate covers ${receipt.coveredObligationIds.length} of ${receipt.obligations.length} explicitly requested targets.`, + slideIds: obligations.map((obligation) => obligation.slideId), + elementIds: obligations.flatMap((obligation) => + obligation.elementId ? [obligation.elementId] : [], + ), + sourceIds: [], + }, + ]; +} + +function checksFromPresentationQuality( + snapshot: DeckSnapshot, + receipt: NodeSlidePresentationQualityReceipt, +): CheckDraft[] { + if ( + receipt.deckId !== snapshot.deck.id || + receipt.deckVersion !== snapshot.deck.version || + !verifyNodeSlidePresentationQualityReceipt(receipt) + ) { + return [ + { + code: 'presentation_quality_receipt_mismatch', + category: 'claims', + origin: 'presentation_quality', + severity: 'critical', + blocker: true, + message: 'The presentation-quality receipt is invalid or belongs to another deck version.', + slideIds: [], + elementIds: [], + sourceIds: [], + }, + ]; + } + return receipt.issues.map((qualityIssue) => ({ + code: qualityIssue.code, + category: + qualityIssue.dimension === 'evidence' + ? 'evidence' + : qualityIssue.dimension === 'visual' + ? 'layout' + : qualityIssue.dimension === 'editability' || qualityIssue.dimension === 'artifact_proof' + ? 'export' + : 'claims', + origin: 'presentation_quality', + severity: qualityIssue.severity, + blocker: qualityIssue.blocker, + message: qualityIssue.message, + slideIds: qualityIssue.slideIds, + elementIds: qualityIssue.elementIds, + sourceIds: [], + })); +} + +function checksFromValidation(snapshot: DeckSnapshot, validation: ValidationResult): CheckDraft[] { + const checks = validation.issues.flatMap((issue) => checksFromValidationIssue(snapshot, issue)); + const errorIssues = validation.issues.filter((issue) => issue.severity === 'error'); + if (!validation.publishOk && errorIssues.length === 0) { + const warnings = validation.issues.filter((issue) => issue.severity === 'warning'); + checks.push({ + code: 'validation_publish_not_ready', + category: 'export', + origin: 'validation', + severity: 'error', + blocker: true, + message: 'The authoritative validation receipt is not ready for publication or export.', + slideIds: warnings.flatMap((issue) => (issue.slideId ? [issue.slideId] : [])), + elementIds: warnings.flatMap((issue) => (issue.elementId ? [issue.elementId] : [])), + sourceIds: [], + }); + } + return checks; +} + +function checksFromValidationIssue(snapshot: DeckSnapshot, issue: ValidationIssue): CheckDraft[] { + const missingSourceIds = missingSourceIdsForIssue(snapshot, issue); + if (missingSourceIds.length > 0) { + return missingSourceIds.map((sourceId) => ({ + code: 'source_reference_missing', + category: 'source_binding', + origin: 'validation', + severity: issue.severity, + blocker: issue.severity === 'error', + message: issue.message, + slideIds: issue.slideId ? [issue.slideId] : [], + elementIds: issue.elementId ? [issue.elementId] : [], + sourceIds: [sourceId], + })); + } + return [ + { + code: `validation_${issue.code}`, + category: validationCategory(issue), + origin: 'validation', + severity: issue.severity, + blocker: issue.severity === 'error', + message: issue.message, + slideIds: issue.slideId ? [issue.slideId] : [], + elementIds: issue.elementId ? [issue.elementId] : [], + sourceIds: [], + }, + ]; +} + +function missingSourceIdsForIssue(snapshot: DeckSnapshot, issue: ValidationIssue): string[] { + if (issue.code !== 'source' || !issue.elementId || !/unknown source/i.test(issue.message)) { + return []; + } + const sourceIds = new Set(snapshot.sources.map((source) => source.id)); + const element = snapshot.elements.find((candidate) => candidate.id === issue.elementId); + if (!element) return []; + return sourceIdsForElement(element).filter((sourceId) => !sourceIds.has(sourceId)); +} + +function validationCategory(issue: ValidationIssue): NodeSlideDeckCiCategory { + if (issue.code === 'export' || issue.code === 'missing_asset') return 'export'; + if ( + issue.code === 'overflow' || + issue.code === 'collision' || + issue.code === 'contrast' || + issue.code === 'font_size' || + issue.code.startsWith('on_brand_') + ) { + return 'layout'; + } + return issue.code === 'source' ? 'source_binding' : 'structure'; +} + +function checksFromHookInputs( + origin: Extract, + inputs: readonly NodeSlideDeckCiHookCheckInput[] | undefined, + category: Extract, +): CheckDraft[] { + return (inputs ?? []) + .filter((input) => input.status !== 'pass') + .map((input) => ({ + code: normalizedHookCode(input.code), + category, + origin, + severity: input.status === 'fail' ? 'error' : 'warning', + blocker: input.blocker ?? input.status === 'fail', + message: normalizedHookMessage(input.message), + slideIds: uniqueSorted(input.slideIds ?? []), + elementIds: uniqueSorted(input.elementIds ?? []), + sourceIds: uniqueSorted(input.sourceIds ?? []), + })); +} + +function normalizedHookCode(value: string): string { + const code = value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/^_+|_+$/g, ''); + if (!code || code.length > 96) throw new Error('Deck CI hook codes must be 1-96 characters.'); + return code; +} + +function normalizedHookMessage(value: string): string { + const message = value.replace(/\s+/g, ' ').trim(); + if (!message || message.length > 500) { + throw new Error('Deck CI hook messages must be 1-500 characters.'); + } + return message; +} + +function materializeChecks( + snapshot: DeckSnapshot, + drafts: readonly CheckDraft[], +): NodeSlideDeckCiCheck[] { + const byIdentity = new Map(); + for (const draft of drafts) { + const normalized: CheckDraft = { + ...draft, + slideIds: orderedSlideIds(snapshot, draft.slideIds), + elementIds: orderedElementIds(snapshot, draft.elementIds), + sourceIds: uniqueSorted(draft.sourceIds), + }; + const identity = stableSerialize(normalized); + const check = { id: nodeslideStableId('deck_ci_check', identity), ...normalized }; + const dedupeKey = stableSerialize({ + code: check.code, + category: check.category, + blocker: check.blocker, + slideIds: check.slideIds, + elementIds: check.elementIds, + sourceIds: check.sourceIds, + }); + const existing = byIdentity.get(dedupeKey); + if (!existing || preferredCheck(check, existing) < 0) byIdentity.set(dedupeKey, check); + } + return [...byIdentity.values()].sort((left, right) => compareChecks(snapshot, left, right)); +} + +function preferredCheck(left: NodeSlideDeckCiCheck, right: NodeSlideDeckCiCheck): number { + const originRank: Record = { + semantic: 0, + presentation_quality: 1, + validation: 2, + layout_structure_hook: 3, + export_readiness_hook: 4, + }; + return originRank[left.origin] - originRank[right.origin]; +} + +function compareChecks( + snapshot: DeckSnapshot, + left: NodeSlideDeckCiCheck, + right: NodeSlideDeckCiCheck, +): number { + const blockerDifference = Number(right.blocker) - Number(left.blocker); + if (blockerDifference !== 0) return blockerDifference; + const severityDifference = SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]; + if (severityDifference !== 0) return severityDifference; + const categoryDifference = CATEGORY_RANK[left.category] - CATEGORY_RANK[right.category]; + if (categoryDifference !== 0) return categoryDifference; + const slideRank = new Map(snapshot.deck.slideOrder.map((id, index) => [id, index])); + const slideDifference = + (slideRank.get(left.slideIds[0] ?? '') ?? Number.MAX_SAFE_INTEGER) - + (slideRank.get(right.slideIds[0] ?? '') ?? Number.MAX_SAFE_INTEGER); + if (slideDifference !== 0) return slideDifference; + return left.code.localeCompare(right.code) || left.id.localeCompare(right.id); +} + +function countSeverities( + checks: readonly NodeSlideDeckCiCheck[], +): Record { + const counts: Record = { + critical: 0, + error: 0, + warning: 0, + info: 0, + }; + for (const check of checks) counts[check.severity] += 1; + return counts; +} + +function sourceChangeImpact( + snapshot: DeckSnapshot, + changedSourceIds: readonly string[], +): NodeSlideDeckCiResult['changedSourceImpact'] { + const changed = uniqueSorted(changedSourceIds); + const knownSourceIds = new Set(snapshot.sources.map((source) => source.id)); + const bindings = snapshot.elements.flatMap((element) => + sourceIdsForElement(element) + .filter((sourceId) => changed.includes(sourceId)) + .map((sourceId) => ({ sourceId, slideId: element.slideId, elementId: element.id })), + ); + const boundSourceIds = uniqueSorted(bindings.map((binding) => binding.sourceId)); + return { + changedSourceIds: changed, + boundSourceIds, + unboundSourceIds: changed.filter( + (sourceId) => knownSourceIds.has(sourceId) && !boundSourceIds.includes(sourceId), + ), + missingSourceIds: changed.filter((sourceId) => !knownSourceIds.has(sourceId)), + slideIds: orderedSlideIds( + snapshot, + bindings.map((binding) => binding.slideId), + ), + elementIds: orderedElementIds( + snapshot, + bindings.map((binding) => binding.elementId), + ), + }; +} + +function sourceIdsForElement(element: SlideElement): string[] { + return uniqueSorted([ + ...element.sourceIds, + ...(element.chart?.sourceId ? [element.chart.sourceId] : []), + ...(element.math?.sourceId ? [element.math.sourceId] : []), + ...(element.image?.sourceId ? [element.image.sourceId] : []), + ]); +} + +function orderedSlideIds(snapshot: DeckSnapshot, ids: readonly string[]): string[] { + const rank = new Map(snapshot.deck.slideOrder.map((id, index) => [id, index])); + return uniqueSorted(ids).sort( + (left, right) => + (rank.get(left) ?? Number.MAX_SAFE_INTEGER) - (rank.get(right) ?? Number.MAX_SAFE_INTEGER) || + left.localeCompare(right), + ); +} + +function orderedElementIds(snapshot: DeckSnapshot, ids: readonly string[]): string[] { + const rank = new Map(); + let index = 0; + for (const slideId of snapshot.deck.slideOrder) { + const slide = snapshot.slides.find((candidate) => candidate.id === slideId); + for (const elementId of slide?.elementOrder ?? []) rank.set(elementId, index++); + } + return uniqueSorted(ids).sort( + (left, right) => + (rank.get(left) ?? Number.MAX_SAFE_INTEGER) - (rank.get(right) ?? Number.MAX_SAFE_INTEGER) || + left.localeCompare(right), + ); +} + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values.filter(Boolean))].sort((left, right) => left.localeCompare(right)); +} + +function stableSerialize(value: unknown): string { + return JSON.stringify(canonicalValue(value)); +} + +function canonicalValue(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalValue); + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value) + .filter(([, item]) => item !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalValue(item)]), + ); + } + if (typeof value === 'number') return Number.isFinite(value) ? value : String(value); + return value; +} diff --git a/convex/lib/nodeslideDeckCiAuthoringQuality.test.ts b/convex/lib/nodeslideDeckCiAuthoringQuality.test.ts new file mode 100644 index 0000000..d13b39c --- /dev/null +++ b/convex/lib/nodeslideDeckCiAuthoringQuality.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it } from 'vitest'; +import type { DeckSnapshot } from '../../shared/nodeslide'; +import { createDefaultNodeSlideAuthoringPolicy } from '../../shared/nodeslideAuthoringPolicy'; +import { evaluateNodeSlidePresentationQuality } from '../../shared/nodeslideAuthoringQuality'; +import { evaluateNodeSlideDeckCi } from './nodeslideDeckCi'; + +describe('Deck CI presentation-quality binding', () => { + it('surfaces a valid release-quality blocker in the deterministic Deck CI receipt', () => { + const deck = snapshot(); + const policy = createDefaultNodeSlideAuthoringPolicy(); + const quality = evaluateNodeSlidePresentationQuality(deck, { policy }); + const result = evaluateNodeSlideDeckCi(deck, { presentationQuality: quality }); + + expect(result.status).toBe('fail'); + expect(result.presentationQuality).toMatchObject({ + digest: quality.digest, + overall: quality.scores.overall, + blockerCount: quality.blockerCount, + }); + expect(result.checks.some((check) => check.origin === 'presentation_quality')).toBe(true); + }); + + it('fails closed when the quality receipt is rebound to another deck version', () => { + const deck = snapshot(); + const quality = evaluateNodeSlidePresentationQuality(deck); + deck.deck.version += 1; + const result = evaluateNodeSlideDeckCi(deck, { presentationQuality: quality }); + expect(result.checks.map((check) => check.code)).toContain( + 'presentation_quality_receipt_mismatch', + ); + }); +}); + +function snapshot(): DeckSnapshot { + return { + deck: { + schemaVersion: 'nodeslide.slidelang/v1', + toolchainVersion: 'local-slidelang-adapter/1.1.0', + id: 'deck:quality', + projectId: 'project:1', + title: 'Generic deck', + brief: { + prompt: 'Make slides.', + audience: 'general', + purpose: 'Overview', + successCriteria: [], + }, + theme: { + id: 'theme:1', + name: 'Default', + mode: 'light', + colors: { + canvas: '#fff', + ink: '#111', + muted: '#666', + accent: '#f60', + accentSoft: '#fee', + insight: '#def', + insightInk: '#123', + trace: '#345', + border: '#ddd', + }, + typography: { display: 'Inter', body: 'Inter', data: 'Mono' }, + defaultRadius: 0, + spacingUnit: 8, + }, + slideOrder: ['slide:1'], + version: 1, + status: 'ready', + createdAt: 1, + updatedAt: 1, + }, + slides: [ + { + id: 'slide:1', + deckId: 'deck:quality', + title: 'Overview', + background: '#fff', + elementOrder: ['element:1'], + version: 1, + }, + ], + elements: [ + { + id: 'element:1', + slideId: 'slide:1', + name: 'Headline', + kind: 'text', + role: 'headline', + bbox: { x: 0.1, y: 0.1, width: 0.8, height: 0.2 }, + rotation: 0, + content: 'Overview', + style: { fontSize: 40 }, + sourceIds: [], + locked: false, + exportCapabilities: ['pptx_editable'], + version: 1, + }, + ], + sources: [], + }; +} diff --git a/convex/lib/nodeslideDeckDeletion.test.ts b/convex/lib/nodeslideDeckDeletion.test.ts index 639d3ce..e59d024 100644 --- a/convex/lib/nodeslideDeckDeletion.test.ts +++ b/convex/lib/nodeslideDeckDeletion.test.ts @@ -65,6 +65,7 @@ class MemoryDatabase { readonly collectCalls: string[] = []; readonly takeCalls: Array<{ tableName: string; count: number }> = []; readonly writes: Array<{ kind: 'delete'; tableName: string; rowId: string }> = []; + readonly storageDeletes: string[] = []; query(tableName: string): MemoryQuery { return new MemoryQuery(this, tableName); @@ -142,7 +143,14 @@ function seedDeck( } function mutationContext(database: MemoryDatabase): MutationCtx { - return { db: database } as unknown as MutationCtx; + return { + db: database, + storage: { + delete: async (storageId: string) => { + database.storageDeletes.push(storageId); + }, + }, + } as unknown as MutationCtx; } describe('deleteDeck', () => { @@ -167,6 +175,12 @@ describe('deleteDeck', () => { ].sort(); expect([...NODESLIDE_DECK_ERASURE_TABLES].sort()).toEqual(expected); + expect(NODESLIDE_DECK_ERASURE_TABLES).toEqual( + expect.arrayContaining(['nodeslide_claim_evidence_receipts', 'nodeslide_source_revisions']), + ); + expect(NODESLIDE_DECK_ERASURE_TABLES.indexOf('nodeslide_claim_evidence_receipts')).toBeLessThan( + NODESLIDE_DECK_ERASURE_TABLES.indexOf('nodeslide_source_revisions'), + ); }); it('denies a wrong owner capability before reading or deleting child data', async () => { @@ -278,10 +292,20 @@ describe('deleteDeck', () => { database.seed(tableName, { id: `${tableName}:target`, [key]: key === 'tenantId' ? 'deck:target:tenant' : 'deck:target', + ...(tableName === 'nodeslide_evidence_steps' + ? { screenshotStorageId: 'storage:target' } + : tableName === 'nodeslide_uploads' + ? { storageId: 'storage:upload-target' } + : {}), }); database.seed(tableName, { id: `${tableName}:other`, [key]: key === 'tenantId' ? 'deck:other:tenant' : 'deck:other', + ...(tableName === 'nodeslide_evidence_steps' + ? { screenshotStorageId: 'storage:other' } + : tableName === 'nodeslide_uploads' + ? { storageId: 'storage:upload-other' } + : {}), }); } @@ -311,6 +335,7 @@ describe('deleteDeck', () => { expect(database.rows('projects')).toEqual([other.project]); expect(database.rows('nodeslide_decks')).not.toContain(target.deck); expect(database.rows('projects')).not.toContain(target.project); + expect(database.storageDeletes).toEqual(['storage:upload-target', 'storage:target']); }); it('rejects an oversized record set before the first write', async () => { diff --git a/convex/lib/nodeslideDeckDeletion.ts b/convex/lib/nodeslideDeckDeletion.ts index e294766..5a7b9ad 100644 --- a/convex/lib/nodeslideDeckDeletion.ts +++ b/convex/lib/nodeslideDeckDeletion.ts @@ -1,5 +1,5 @@ import { getConvexSize } from 'convex/values'; -import type { Doc } from '../_generated/dataModel'; +import type { Doc, Id } from '../_generated/dataModel'; import type { MutationCtx } from '../_generated/server'; export const NODESLIDE_DECK_ERASURE_TABLES = [ @@ -14,6 +14,11 @@ export const NODESLIDE_DECK_ERASURE_TABLES = [ 'nodeslide_comments', 'nodeslide_versions', 'nodeslide_sources', + 'nodeslide_claim_evidence_receipts', + 'nodeslide_source_revisions', + 'nodeslide_uploads', + 'nodeslide_evidence_steps', + 'nodeslide_evidence_captures', 'nodeslide_sync_connections', 'nodeslide_oauth_sessions', 'nodeslide_oauth_credentials', @@ -40,7 +45,7 @@ export const NODESLIDE_DECK_ERASURE_TABLES = [ export const NODESLIDE_DECK_ERASURE_MAX_RECORDS = 4_000; export const NODESLIDE_DECK_ERASURE_MAX_BYTES = 4 * 1024 * 1024; -type DeleteDeckCtx = Pick; +type DeleteDeckCtx = Pick; type ErasureTable = (typeof NODESLIDE_DECK_ERASURE_TABLES)[number]; type ErasureRow = Doc; @@ -88,6 +93,7 @@ export async function deleteNodeSlideDeckRows( } const childGroups: ErasureRow[][] = []; + const attachmentStorageIds = new Set>(); let deletionRecords = 2; let deletionBytes = getConvexSize(deck) + getConvexSize(project); @@ -167,6 +173,41 @@ export async function deleteNodeSlideDeckRows( .withIndex('by_deck', (query) => query.eq('deckId', deck.id)) .take(nextLimit()), ); + addGroup( + await ctx.db + .query('nodeslide_claim_evidence_receipts') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(nextLimit()), + ); + addGroup( + await ctx.db + .query('nodeslide_source_revisions') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(nextLimit()), + ); + const uploads = await ctx.db + .query('nodeslide_uploads') + .withIndex('by_deck_updated', (query) => query.eq('deckId', deck.id)) + .take(nextLimit()); + for (const upload of uploads) { + if (upload.storageId) attachmentStorageIds.add(upload.storageId); + } + addGroup(uploads); + const evidenceSteps = await ctx.db + .query('nodeslide_evidence_steps') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(nextLimit()); + for (const step of evidenceSteps) { + if (step.screenshotStorageId) attachmentStorageIds.add(step.screenshotStorageId); + if (step.pdfStorageId) attachmentStorageIds.add(step.pdfStorageId); + } + addGroup(evidenceSteps); + addGroup( + await ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_deck_created', (query) => query.eq('deckId', deck.id)) + .take(nextLimit()), + ); addGroup( await ctx.db .query('nodeslide_sync_connections') @@ -276,6 +317,7 @@ export async function deleteNodeSlideDeckRows( .take(nextLimit()), ); + for (const storageId of attachmentStorageIds) await ctx.storage.delete(storageId); for (const rows of childGroups) { for (const row of rows) await ctx.db.delete(row._id); } diff --git a/convex/lib/nodeslideDurableSessionState.ts b/convex/lib/nodeslideDurableSessionState.ts index 061914d..a5b916c 100644 --- a/convex/lib/nodeslideDurableSessionState.ts +++ b/convex/lib/nodeslideDurableSessionState.ts @@ -395,15 +395,21 @@ function rotateEgress( if (!job || job.status === 'awaiting_review' || isTerminal(job.status)) { return { ...state, egressEpoch: nextEpoch, stateVersion: state.stateVersion + 1 }; } + const reason = + boundedNodeSlideReason(command.reason) ?? + 'Egress epoch rotated; the previous execution was fenced.'; const nextJob: NodeSlideDurableJobState = { - ...job, + jobId: job.jobId, + requestBinding: job.requestBinding, status: 'stale', + attempt: job.attempt, + retryCount: job.retryCount, + resumeCount: job.resumeCount, + maxAttempts: job.maxAttempts, + createdAt: job.createdAt, updatedAt: command.now, completedAt: command.now, - ...(boundedNodeSlideReason(command.reason) !== undefined - ? { reason: boundedNodeSlideReason(command.reason) } - : { reason: 'Egress epoch rotated; the previous execution was fenced.' }), - lease: undefined, + reason, }; const next = appendEvent( { ...state, egressEpoch: nextEpoch }, diff --git a/convex/lib/nodeslideEditPlanner.test.ts b/convex/lib/nodeslideEditPlanner.test.ts index 88ed44e..4db793d 100644 --- a/convex/lib/nodeslideEditPlanner.test.ts +++ b/convex/lib/nodeslideEditPlanner.test.ts @@ -190,6 +190,52 @@ describe('NodeSlide baseline edit planner extraction', () => { if (result.ok) expect(result.receipt.providerOutcome).toBe('not_requested'); }); + it('turns retained web research into a reviewable source-bound proposal', async () => { + const { snapshot, target } = fixture(); + const source = snapshot.sources[0]; + const slide = snapshot.slides.find((candidate) => candidate.id === target.slideId); + if (!source || !slide) throw new Error('Expected source and slide fixtures.'); + const scope: PatchScope = { + kind: 'slide', + deckId: snapshot.deck.id, + slideIds: [slide.id], + operationMode: 'unrestricted', + }; + const planningInput = { + ...input(snapshot, target, scope), + readContext: { + references: [{ id: source.id, kind: 'source' as const, label: source.title }], + slides: [slide], + elements: snapshot.elements.filter((element) => element.slideId === slide.id), + sources: [source], + comments: [], + }, + }; + planningInput.request.instruction = + 'Search the web for the latest public information supporting this claim.'; + planningInput.request.providerMode = 'deterministic'; + planningInput.request.focusSlideId = slide.id; + planningInput.request.requireFactualSourceBindings = true; + + const result = await planNodeSlideEdit(planningInput); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.operations).toHaveLength(1); + expect(result.operations[0]).toMatchObject({ + op: 'replace_text', + slideId: slide.id, + sourceIds: [source.id], + }); + expect(result.operations[0]?.op === 'replace_text' && result.operations[0].text).toMatch( + / \[evidence\]$/, + ); + expect(result.receipt).toMatchObject({ + origin: 'deterministic_fallback', + terminalOutcome: 'completed', + }); + }); + it('keeps an explicitly requested element removal as a reviewable provider proposal', async () => { const { snapshot, target, scope } = fixture(); scope.operationMode = 'unrestricted'; @@ -421,11 +467,24 @@ describe('NodeSlide baseline edit planner extraction', () => { text: 'Launch-ready decisions stay reviewable', }, ]); + expect(result.receipt.semanticCoverage).toMatchObject({ + status: 'pass', + missingObligationIds: [], + obligations: [ + expect.objectContaining({ + field: 'headline', + elementId: headline.id, + expectedText: 'Launch-ready decisions stay reviewable', + }), + ], + }); }); it('accepts valid provider operations and derives its summary from the validated diff', async () => { const { snapshot, target, scope } = fixture(); const before = structuredClone(snapshot); + const planningInput = input(snapshot, target, scope); + planningInput.request.instruction = 'Rewrite the selected text.'; const provider = vi.fn(async () => ({ ok: true as const, value: { @@ -448,7 +507,7 @@ describe('NodeSlide baseline edit planner extraction', () => { }, })); - const result = await planNodeSlideEdit(input(snapshot, target, scope), { + const result = await planNodeSlideEdit(planningInput, { callProvider: provider, }); @@ -478,6 +537,116 @@ describe('NodeSlide baseline edit planner extraction', () => { expect(snapshot).toEqual(before); }); + it('rejects the observed five-field rewrite when the provider returns one unrelated move', async () => { + const snapshot = buildGoldenNodeSlide('five-field-semantic-coverage', NOW).snapshot; + const slide = snapshot.slides[0]; + if (!slide) throw new Error('Expected opening slide fixture.'); + const slideElements = snapshot.elements.filter((element) => element.slideId === slide.id); + const section = slideElements.find((element) => element.name === 'Section label'); + if (!section) throw new Error('Expected section label fixture.'); + const scope: PatchScope = { + kind: 'slide', + deckId: snapshot.deck.id, + slideIds: [slide.id], + operationMode: 'unrestricted', + }; + const planningInput = input(snapshot, section, scope); + planningInput.request.focusSlideId = slide.id; + planningInput.request.instruction = + 'Rewrite the section label, headline, body copy, key point 1, and key point 2. Change nothing else.'; + + const result = await planNodeSlideEdit(planningInput, { + callProvider: async () => ({ + ok: true, + value: { + summary: 'Moved one label', + operations: [ + { + op: 'move', + slideId: slide.id, + elementId: section.id, + x: section.bbox.x + 0.01, + y: section.bbox.y, + }, + ], + }, + telemetry: { + provider: NODESLIDE_EDIT_PROVIDER, + model: NODESLIDE_EDIT_MODEL, + costMicroUsd: 10, + inputTokens: 100, + outputTokens: 20, + }, + }), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe('fallback_unavailable'); + expect(result.receipt.providerOutcome).toBe('invalid'); + expect(result.receipt.fallbackReason).toContain('semantic coverage was incomplete (0/5'); + expect(result.receipt.semanticCoverage).toMatchObject({ + status: 'blocked', + coveredObligationIds: [], + }); + expect(result.receipt.semanticCoverage?.missingObligationIds).toHaveLength(5); + }); + + it('preserves a narrow one-field headline rewrite with a passing coverage receipt', async () => { + const snapshot = buildGoldenNodeSlide('one-field-semantic-coverage', NOW).snapshot; + const slide = snapshot.slides[0]; + const headline = snapshot.elements.find( + (element) => + element.slideId === slide?.id && + !element.locked && + element.kind === 'text' && + (element.role === 'title' || element.role === 'headline'), + ); + if (!slide || !headline) throw new Error('Expected opening headline fixture.'); + const scope: PatchScope = { + kind: 'slide', + deckId: snapshot.deck.id, + slideIds: [slide.id], + operationMode: 'copy', + }; + const planningInput = input(snapshot, headline, scope); + planningInput.request.focusSlideId = slide.id; + planningInput.request.instruction = 'Rewrite the headline to be more decisive.'; + + const result = await planNodeSlideEdit(planningInput, { + callProvider: async () => ({ + ok: true, + value: { + summary: 'Sharper headline', + operations: [ + { + op: 'replace_text', + slideId: slide.id, + elementId: headline.id, + text: 'Make every decision reviewable.', + }, + ], + }, + telemetry: { + provider: NODESLIDE_EDIT_PROVIDER, + model: NODESLIDE_EDIT_MODEL, + costMicroUsd: 10, + inputTokens: 100, + outputTokens: 20, + }, + }), + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.receipt.origin).toBe('free_route'); + expect(result.receipt.semanticCoverage).toMatchObject({ + status: 'pass', + missingObligationIds: [], + }); + expect(result.receipt.semanticCoverage?.obligations).toHaveLength(1); + }); + it('passes the user-selected catalog model to the pi-ai provider boundary', async () => { const { snapshot, target, scope } = fixture(); const planningInput = input(snapshot, target, scope); @@ -529,6 +698,7 @@ describe('NodeSlide baseline edit planner extraction', () => { }, }; planningInput.request.requireFactualSourceBindings = true; + planningInput.request.instruction = 'Rewrite the selected text using the supplied evidence.'; const provider = vi.fn(async () => ({ ok: true, value: { @@ -940,7 +1110,7 @@ describe('NodeSlide baseline edit planner extraction', () => { }, ]); expect(result.receipt).toMatchObject({ - adapterVersion: '1.5.0', + adapterVersion: '1.7.0', origin: 'deterministic_fallback', providerOutcome: 'failed', terminalOutcome: 'completed', @@ -986,7 +1156,7 @@ describe('NodeSlide baseline edit planner extraction', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.receipt).toMatchObject({ - adapterVersion: '1.5.0', + adapterVersion: '1.7.0', origin: 'deterministic_fallback', providerOutcome: 'invalid', terminalOutcome: 'completed', @@ -1074,7 +1244,7 @@ describe('NodeSlide baseline edit planner extraction', () => { expect(result.ok).toBe(true); if (!result.ok) return; expect(result.receipt).toMatchObject({ - adapterVersion: '1.5.0', + adapterVersion: '1.7.0', origin: 'deterministic_fallback', providerOutcome: 'invalid', terminalOutcome: 'completed', diff --git a/convex/lib/nodeslideEditPlanner.ts b/convex/lib/nodeslideEditPlanner.ts index 8f13d18..d3aff4e 100644 --- a/convex/lib/nodeslideEditPlanner.ts +++ b/convex/lib/nodeslideEditPlanner.ts @@ -15,6 +15,10 @@ import { type PatchScope, nodeSlideAgentModel, } from '../../shared/nodeslide'; +import { + type NodeSlideSemanticCoverageReceipt, + evaluateNodeSlideSemanticCoverage, +} from './nodeslideCandidate'; import { nodeSlideMemoryUse } from './nodeslideMemoryPolicy'; import { deterministicAgentOperations, @@ -30,7 +34,7 @@ import type { ResolvedNodeSlideReadContext } from './nodeslideReadContext'; import { buildNodeSlideSourceLineage } from './nodeslideSourceLineage'; export const NODESLIDE_BASELINE_EDIT_ADAPTER_ID = 'nodeslide/single-shot-edit-planner' as const; -export const NODESLIDE_BASELINE_EDIT_ADAPTER_VERSION = '1.5.0' as const; +export const NODESLIDE_BASELINE_EDIT_ADAPTER_VERSION = '1.7.0' as const; const NODESLIDE_EDIT_RESPONSE_SCHEMA = { type: 'object', @@ -214,6 +218,7 @@ export interface NodeSlideEditPlannerReceipt { terminalOutcome: 'completed' | 'fallback_unavailable' | 'proposal_invalid'; fallbackReason?: string; providerTelemetry?: NodeSlideProviderTelemetry; + semanticCoverage?: NodeSlideSemanticCoverageReceipt; } export type NodeSlideEditPlanningOutcome = @@ -286,6 +291,7 @@ export async function planNodeSlideEdit( let operations: PatchOperation[] | null = null; let providerInvalidReason = `the ${providerLabel} response was invalid`; let providerIntentViolation: string | null = null; + let providerSemanticCoverage: NodeSlideSemanticCoverageReceipt | undefined; let providerOutcome: NodeSlideEditPlannerReceipt['providerOutcome'] = request.providerMode === 'deterministic' ? 'not_requested' : provider.ok ? 'invalid' : 'failed'; if (provider.ok) { @@ -325,7 +331,20 @@ export async function planNodeSlideEdit( if (errors.length > 0) { operations = null; providerInvalidReason = `candidate validation rejected the ${providerLabel} response: ${errors[0]}`; - } else providerOutcome = 'accepted'; + } else { + const semanticCoverage = evaluateNodeSlideSemanticCoverage({ + snapshot, + instruction: request.instruction, + scope: request.scope, + operations, + ...(request.focusSlideId ? { focusSlideId: request.focusSlideId } : {}), + }); + providerSemanticCoverage = semanticCoverage; + if (semanticCoverage.status === 'blocked') { + operations = null; + providerInvalidReason = `${providerLabel} semantic coverage was incomplete (${semanticCoverage.coveredObligationIds.length}/${semanticCoverage.obligations.length} explicit targets covered)`; + } else providerOutcome = 'accepted'; + } } } @@ -343,6 +362,7 @@ export async function planNodeSlideEdit( ...('telemetry' in provider && provider.telemetry ? { providerTelemetry: provider.telemetry } : {}), + ...(providerSemanticCoverage ? { semanticCoverage: providerSemanticCoverage } : {}), }; let finalOperations: PatchOperation[]; @@ -361,16 +381,21 @@ export async function planNodeSlideEdit( receipt: { ...receiptBase, terminalOutcome: 'proposal_invalid' }, }; } - const message = - error instanceof Error && error.message.startsWith(`The ${providerLabel} route returned`) - ? error.message - : `The ${providerLabel} route could not produce a safe scoped proposal, and the deterministic fallback could not safely infer a valid edit. Retry with a smaller request or exact replacement copy in quotation marks.`; - return { - ok: false, - code: 'fallback_unavailable', - message, - receipt: { ...receiptBase, terminalOutcome: 'fallback_unavailable' }, - }; + const evidenceBindingOperations = deterministicEvidenceBindingOperations(request, readContext); + if (evidenceBindingOperations) { + finalOperations = evidenceBindingOperations; + } else { + const message = + error instanceof Error && error.message.startsWith(`The ${providerLabel} route returned`) + ? error.message + : `The ${providerLabel} route could not produce a safe scoped proposal, and the deterministic fallback could not safely infer a valid edit. Retry with a smaller request or exact replacement copy in quotation marks.`; + return { + ok: false, + code: 'fallback_unavailable', + message, + receipt: { ...receiptBase, terminalOutcome: 'fallback_unavailable' }, + }; + } } const finalIntentViolation = explicitStructuralIntentViolation( @@ -400,14 +425,85 @@ export async function planNodeSlideEdit( }; } + const semanticCoverage = evaluateNodeSlideSemanticCoverage({ + snapshot, + instruction: request.instruction, + scope: request.scope, + operations: finalOperations, + ...(request.focusSlideId ? { focusSlideId: request.focusSlideId } : {}), + }); + if (semanticCoverage.status === 'blocked') { + return { + ok: false, + code: 'proposal_invalid', + message: `No deck change was made because the proposal covered ${semanticCoverage.coveredObligationIds.length} of ${semanticCoverage.obligations.length} explicitly requested targets. Retry with fewer fields or exact replacement copy.`, + receipt: { ...receiptBase, semanticCoverage, terminalOutcome: 'proposal_invalid' }, + }; + } + return { ok: true, operations: finalOperations, summary: summarizePatchOperations(finalOperations, snapshot), - receipt: { ...receiptBase, terminalOutcome: 'completed' }, + receipt: { ...receiptBase, semanticCoverage, terminalOutcome: 'completed' }, }; } +const RESEARCH_BINDING_INTENT = /\b(?:cite|evidence|find|research|search|source|support)\b/i; +const RESEARCH_BINDING_MARKER = ' [evidence]'; + +/** + * A research-only turn can successfully retain evidence without asking for + * replacement copy. The patch model has no provenance-only operation, so + * create one honest, visible citation marker on an authorized text element + * and bind the retained source IDs atomically. This is still only a proposal; + * review remains mandatory before the deck changes. + */ +function deterministicEvidenceBindingOperations( + request: NodeSlideEditPlanningRequest, + readContext: ResolvedNodeSlideReadContext, +): PatchOperation[] | null { + if ( + !request.requireFactualSourceBindings || + !RESEARCH_BINDING_INTENT.test(request.instruction) || + readContext.sources.length === 0 + ) { + return null; + } + + const candidates = readContext.elements.filter( + (element) => + element.kind === 'text' && + !element.locked && + typeof element.content === 'string' && + element.content.trim().length > 0 && + !element.content.endsWith(RESEARCH_BINDING_MARKER), + ); + const target = + candidates.find( + (element) => + element.slideId === request.focusSlideId && + (element.role === 'body' || element.role === 'headline' || element.role === 'title'), + ) ?? + candidates.find( + (element) => + element.role === 'body' || element.role === 'headline' || element.role === 'title', + ) ?? + candidates[0]; + const targetContent = target?.content; + if (!target || typeof targetContent !== 'string') return null; + + return [ + { + op: 'replace_text', + slideId: target.slideId, + elementId: target.id, + text: `${targetContent.trimEnd()}${RESEARCH_BINDING_MARKER}`, + sourceIds: readContext.sources.map((source) => source.id), + }, + ]; +} + export function buildNodeSlideEditProviderInput( snapshot: DeckSnapshot, request: NodeSlideEditPlanningRequest, diff --git a/convex/lib/nodeslideEditShadowAction.test.ts b/convex/lib/nodeslideEditShadowAction.test.ts index c3385ba..a7f4cd5 100644 --- a/convex/lib/nodeslideEditShadowAction.test.ts +++ b/convex/lib/nodeslideEditShadowAction.test.ts @@ -85,7 +85,7 @@ function fixture() { const args: ProposeArgs = { deckId: snapshot.deck.id, ownerAccessKey: OWNER_ACCESS_KEY, - instruction: 'Replace "Before" with "CANDIDATE_ONLY".', + instruction: 'Rewrite the selected text using "CANDIDATE_ONLY" as the shadow direction.', baseDeckVersion: snapshot.deck.version, baseSlideVersions: { [slide.id]: slide.version }, baseElementVersions: { [target.id]: target.version }, diff --git a/convex/lib/nodeslideEvidenceCapture.test.ts b/convex/lib/nodeslideEvidenceCapture.test.ts new file mode 100644 index 0000000..ef38470 --- /dev/null +++ b/convex/lib/nodeslideEvidenceCapture.test.ts @@ -0,0 +1,589 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + NODESLIDE_CAPTURE_MAX_PROVIDER_BYTES, + NODESLIDE_CAPTURE_MAX_SCREENSHOT_BYTES, + captureNodeSlideWebEvidence, + captureNodeSlideWebEvidenceBatch, + createNodeSlideSourceSnapshotPdf, + readRasterImageDimensions, + safePublicCaptureUrl, +} from './nodeslideEvidenceCapture'; +import { nodeslideContentDigest } from './nodeslideIds'; + +const SOURCE_URL = 'https://example.com/reports/quarterly'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('safePublicCaptureUrl', () => { + it.each([ + 'http://localhost/admin', + 'https://preview.localhost/', + 'http://0.0.0.0/', + 'http://127.0.0.1/', + 'http://10.20.30.40/', + 'http://172.16.0.1/', + 'http://172.31.255.255/', + 'http://192.168.1.1/', + 'http://169.254.169.254/latest/meta-data/', + 'http://[::1]/', + ])('blocks localhost or private-network source %s', (url) => { + expect(() => safePublicCaptureUrl(url)).toThrow( + 'Visual evidence capture cannot access private network addresses.', + ); + }); + + it.each(['file:///etc/passwd', 'ftp://example.com/report', 'data:text/plain,secret'])( + 'blocks non-HTTP source %s', + (url) => { + expect(() => safePublicCaptureUrl(url)).toThrow( + 'Visual evidence capture requires an HTTP or HTTPS source.', + ); + }, + ); + + it('strips fragments while preserving the public URL path and query', () => { + expect(safePublicCaptureUrl('https://Example.COM/report?q=deck#private-fragment')).toBe( + 'https://example.com/report?q=deck', + ); + }); +}); + +describe('captureNodeSlideWebEvidence', () => { + it('fails without an API key before making any request', async () => { + const fetchMock = vi.fn(); + + const result = await captureNodeSlideWebEvidence({ + url: `${SOURCE_URL}#credentials`, + apiKey: ' ', + fetchImpl: fetchMock, + now: sequenceNow(100, 125), + }); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + ok: false, + status: 'failed', + provider: 'firecrawl', + url: SOURCE_URL, + title: 'example.com', + markdown: '', + error: 'Visual capture is not configured on this deployment.', + issue: { + code: 'configuration_missing', + stage: 'configuration', + message: 'Visual capture is not configured on this deployment.', + }, + startedAt: 100, + completedAt: 125, + }); + }); + + it.each([ + 'http://169.254.169.254/internal.png', + 'https://127.0.0.1/internal.png', + 'file:///tmp/internal.png', + ])('rejects unsafe screenshot URL %s without fetching the attachment', async (screenshot) => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + success: true, + data: { + markdown: 'Provider supplied text', + screenshot, + }, + }), + ); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + now: sequenceNow(200, 250), + }); + + expect(result).toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual attachment URL was invalid or did not use HTTPS.', + issue: { code: 'screenshot_url_invalid', stage: 'screenshot' }, + startedAt: 200, + completedAt: 250, + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('returns typed degraded evidence when the screenshot fetch fails', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + success: true, + data: { + markdown: 'Provider supplied text remains usable.', + screenshot: 'https://cdn.example.com/capture.png', + }, + }), + ) + .mockResolvedValueOnce(new Response(null, { status: 502 })); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ + ok: true, + status: 'degraded', + markdown: 'Provider supplied text remains usable.', + issue: { + code: 'screenshot_http_error', + stage: 'screenshot', + message: 'Visual attachment provider returned HTTP 502.', + }, + }); + expect(result.screenshot).toBeUndefined(); + }); + + it('caps the normalized markdown response', async () => { + const fetchMock = vi.fn().mockResolvedValueOnce( + jsonResponse({ + success: true, + data: { markdown: ` ${'x'.repeat(120_010)}\r\nignored ` }, + }), + ); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(result.ok).toBe(true); + expect(result.markdown).toHaveLength(120_000); + expect(result.markdown).toBe('x'.repeat(120_000)); + }); + + it('rejects a screenshot exceeding its declared size', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + success: true, + data: { + screenshot: 'https://cdn.example.com/capture.png', + }, + }), + ) + .mockResolvedValueOnce( + new Response(new Uint8Array([1]), { + headers: { + 'content-length': String(NODESLIDE_CAPTURE_MAX_SCREENSHOT_BYTES + 1), + 'content-type': 'image/png', + }, + }), + ); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual capture exceeded the screenshot size limit.', + issue: { code: 'screenshot_too_large', stage: 'screenshot' }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rejects a screenshot whose actual body exceeds the size cap', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + jsonResponse({ + success: true, + data: { screenshot: 'https://cdn.example.com/capture.webp' }, + }), + ) + .mockResolvedValueOnce( + chunkedResponse( + [new Uint8Array(NODESLIDE_CAPTURE_MAX_SCREENSHOT_BYTES), new Uint8Array([1])], + { + headers: { 'content-type': 'image/webp' }, + }, + ), + ); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual capture exceeded the screenshot size limit.', + issue: { code: 'screenshot_too_large', stage: 'screenshot' }, + }); + }); + + it('rejects an oversized chunked provider response before buffering beyond the cap', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + chunkedResponse([ + new TextEncoder().encode('{"success":true,"data":{"markdown":"'), + new Uint8Array(NODESLIDE_CAPTURE_MAX_PROVIDER_BYTES), + new TextEncoder().encode('"}}'), + ]), + ); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual capture provider response exceeded the size limit.', + issue: { code: 'provider_response_too_large', stage: 'provider' }, + }); + }); + + it('parses valid provider JSON split across response chunks', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + chunkedResponse([ + new TextEncoder().encode('{"success":true,"data":'), + new TextEncoder().encode('{"markdown":"chunked evidence"}}'), + ]), + ); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ + ok: true, + status: 'degraded', + markdown: 'chunked evidence', + issue: { code: 'provider_evidence_missing', stage: 'screenshot' }, + }); + }); + + it('returns a typed failure for malformed provider JSON', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(chunkedResponse([new TextEncoder().encode('{"success": true')])); + + const result = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(result).toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual capture provider returned invalid JSON.', + issue: { code: 'provider_response_invalid', stage: 'provider' }, + }); + }); + + it('keeps source mapping stable when malformed and blocked URLs are interleaved', async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + jsonResponse({ success: true, data: { markdown: 'retained public evidence' } }), + ), + ); + + const results = await captureNodeSlideWebEvidenceBatch({ + targets: [ + { sourceId: 'source-public-a', url: 'https://example.com/a#fragment' }, + { sourceId: 'source-malformed', url: 'not a url' }, + { sourceId: 'source-private', url: 'http://127.0.0.1/private' }, + { sourceId: 'source-public-b', url: 'https://example.org/b' }, + ], + apiKey: 'test-key', + fetchImpl: fetchMock, + }); + + expect(results).toHaveLength(4); + expect(results.map(({ sourceId, sourceIndex }) => [sourceId, sourceIndex])).toEqual([ + ['source-public-a', 0], + ['source-malformed', 1], + ['source-private', 2], + ['source-public-b', 3], + ]); + expect(results[0]).toMatchObject({ + requestedUrl: 'https://example.com/a', + capture: { ok: true, url: 'https://example.com/a' }, + }); + expect(results[1]).toMatchObject({ + requestedUrl: 'invalid-source-url', + capture: { + ok: false, + status: 'failed', + url: 'invalid-source-url', + issue: { code: 'source_url_invalid', stage: 'source' }, + }, + }); + expect(results[2]).toMatchObject({ + requestedUrl: 'http://127.0.0.1/private', + capture: { + ok: false, + status: 'failed', + issue: { code: 'source_url_blocked', stage: 'source' }, + }, + }); + expect(results[3]).toMatchObject({ + requestedUrl: 'https://example.org/b', + capture: { ok: true, url: 'https://example.org/b' }, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('labels provider HTTP errors and fetch failures distinctly', async () => { + const httpFailure = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: vi.fn().mockResolvedValue(new Response(null, { status: 503 })), + }); + const networkFailure = await captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: vi.fn().mockRejectedValue(new Error('socket details must not leak')), + }); + + expect(httpFailure).toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual capture provider returned HTTP 503.', + issue: { code: 'provider_http_error', stage: 'provider' }, + }); + expect(networkFailure).toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual capture failed safely.', + issue: { code: 'network_error', stage: 'network' }, + }); + }); + + it('labels an aborted request as a timeout', async () => { + vi.useFakeTimers(); + const fetchMock = vi.fn((_input, init) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(new DOMException('Aborted', 'AbortError')), + { once: true }, + ); + }); + }); + + const pending = captureNodeSlideWebEvidence({ + url: SOURCE_URL, + apiKey: 'test-key', + fetchImpl: fetchMock, + timeoutMs: 1, + }); + await vi.advanceTimersByTimeAsync(1_000); + + await expect(pending).resolves.toMatchObject({ + ok: false, + status: 'failed', + error: 'Visual capture timed out.', + issue: { code: 'timeout', stage: 'network' }, + }); + }); + + it('captures normalized markdown and image evidence with a deterministic content digest', async () => { + const screenshotBytes = pngHeader(1_440, 900); + const fetchMock = vi.fn((input) => { + const url = String(input); + if (url === 'https://api.firecrawl.dev/v1/scrape') { + return Promise.resolve( + jsonResponse({ + success: true, + data: { + markdown: ' \u0000# Evidence\r\n\r\nRevenue: 42 ', + screenshot: 'https://cdn.example.com/capture.png', + metadata: { + title: ' Quarterly evidence ', + sourceURL: `${SOURCE_URL}#provider-fragment`, + }, + }, + }), + ); + } + if (url === 'https://cdn.example.com/capture.png') { + return Promise.resolve( + new Response( + screenshotBytes.buffer.slice( + screenshotBytes.byteOffset, + screenshotBytes.byteOffset + screenshotBytes.byteLength, + ) as ArrayBuffer, + { + headers: { + 'content-length': String(screenshotBytes.byteLength), + 'content-type': 'image/png; charset=binary', + }, + }, + ), + ); + } + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }); + + const result = await captureNodeSlideWebEvidence({ + url: `${SOURCE_URL}#client-fragment`, + apiKey: ' secret-test-key ', + fetchImpl: fetchMock, + now: sequenceNow(1_000, 1_025), + }); + + expect(result).toEqual({ + ok: true, + status: 'captured', + provider: 'firecrawl', + url: SOURCE_URL, + title: 'Quarterly evidence', + markdown: '# Evidence\n\nRevenue: 42', + contentDigest: nodeslideContentDigest(screenshotBytes), + screenshot: { + bytes: screenshotBytes, + mimeType: 'image/png', + viewport: { width: 1_440, height: 900 }, + }, + startedAt: 1_000, + completedAt: 1_025, + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + 'https://api.firecrawl.dev/v1/scrape', + expect.objectContaining({ + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer secret-test-key', + }, + body: JSON.stringify({ + url: SOURCE_URL, + formats: ['markdown', 'screenshot'], + onlyMainContent: true, + }), + signal: expect.any(AbortSignal), + }), + ); + expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://cdn.example.com/capture.png', { + signal: expect.any(AbortSignal), + }); + }); +}); + +describe('readRasterImageDimensions', () => { + it('derives exact PNG dimensions from encoded bytes and rejects truncated headers', () => { + expect(readRasterImageDimensions(pngHeader(1_920, 1_080))).toEqual({ + width: 1_920, + height: 1_080, + }); + expect(readRasterImageDimensions(new Uint8Array([137, 80, 78, 71]))).toBeUndefined(); + }); +}); + +describe('createNodeSlideSourceSnapshotPdf', () => { + it('creates a bounded, digest-bound PDF with a normalized excerpt overlay', () => { + const snapshot = createNodeSlideSourceSnapshotPdf({ + title: 'Quarterly evidence (audited)', + url: `${SOURCE_URL}#section`, + excerpt: 'Revenue reached 42 million in the retained source excerpt.', + provider: 'linkup', + retrievedAt: Date.parse('2026-07-16T10:00:00.000Z'), + }); + const text = new TextDecoder().decode(snapshot.bytes); + + expect(text.startsWith('%PDF-1.4')).toBe(true); + expect(text).toContain('Bound source excerpt:'); + expect(text).toContain('Revenue reached 42 million'); + expect(text).toContain('https://example.com/reports/quarterly'); + const authoredRegion = text.match(/48 (\d+) 516 (\d+) re f/); + expect(authoredRegion).not.toBeNull(); + const regionBottom = Number(authoredRegion?.[1]); + const regionHeight = Number(authoredRegion?.[2]); + expect(snapshot.contentDigest).toBe(nodeslideContentDigest(snapshot.bytes)); + expect(snapshot.viewport).toEqual({ width: 612, height: 792 }); + expect(snapshot.box).toMatchObject({ page: 1 }); + expect(snapshot.box.x * 612).toBeCloseTo(48); + expect(snapshot.box.y * 792).toBeCloseTo(792 - regionBottom - regionHeight); + expect(snapshot.box.w * 612).toBeCloseTo(516); + expect(snapshot.box.h * 792).toBeCloseTo(regionHeight); + expect(snapshot.box.x + snapshot.box.w).toBeLessThanOrEqual(1); + expect(snapshot.box.y + snapshot.box.h).toBeLessThanOrEqual(1); + }); +}); + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); +} + +function chunkedResponse(chunks: readonly Uint8Array[], init?: ResponseInit): Response { + let index = 0; + return new Response( + new ReadableStream({ + pull(controller) { + const chunk = chunks[index++]; + if (chunk) controller.enqueue(chunk); + else controller.close(); + }, + }), + { status: 200, ...init }, + ); +} + +function sequenceNow(...values: number[]): () => number { + let index = 0; + return () => values[Math.min(index++, values.length - 1)] ?? 0; +} + +function pngHeader(width: number, height: number): Uint8Array { + return new Uint8Array([ + 0x89, + 0x50, + 0x4e, + 0x47, + 0x0d, + 0x0a, + 0x1a, + 0x0a, + 0x00, + 0x00, + 0x00, + 0x0d, + 0x49, + 0x48, + 0x44, + 0x52, + (width >>> 24) & 0xff, + (width >>> 16) & 0xff, + (width >>> 8) & 0xff, + width & 0xff, + (height >>> 24) & 0xff, + (height >>> 16) & 0xff, + (height >>> 8) & 0xff, + height & 0xff, + ]); +} diff --git a/convex/lib/nodeslideEvidenceCapture.ts b/convex/lib/nodeslideEvidenceCapture.ts new file mode 100644 index 0000000..7d79266 --- /dev/null +++ b/convex/lib/nodeslideEvidenceCapture.ts @@ -0,0 +1,675 @@ +import { nodeslideContentDigest } from './nodeslideIds'; + +export const NODESLIDE_CAPTURE_MAX_SCREENSHOT_BYTES = 4_000_000; +export const NODESLIDE_CAPTURE_MAX_PROVIDER_BYTES = 512_000; +export const NODESLIDE_CAPTURE_TIMEOUT_MS = 15_000; +const FIRECRAWL_ENDPOINT = 'https://api.firecrawl.dev/v1/scrape'; + +export type NodeSlideEvidenceCaptureIssueCode = + | 'configuration_missing' + | 'source_url_invalid' + | 'source_url_blocked' + | 'provider_http_error' + | 'provider_response_too_large' + | 'provider_response_invalid' + | 'provider_evidence_missing' + | 'screenshot_url_invalid' + | 'screenshot_http_error' + | 'screenshot_too_large' + | 'screenshot_empty' + | 'timeout' + | 'network_error'; + +export interface NodeSlideEvidenceCaptureIssue { + code: NodeSlideEvidenceCaptureIssueCode; + stage: 'configuration' | 'source' | 'provider' | 'screenshot' | 'network'; + message: string; +} + +export interface NodeSlideCapturedWebEvidence { + ok: boolean; + status: 'captured' | 'degraded' | 'failed'; + provider: 'firecrawl'; + url: string; + title: string; + markdown: string; + contentDigest?: string; + screenshot?: { + bytes: Uint8Array; + mimeType: 'image/png' | 'image/jpeg' | 'image/webp'; + /** Exact encoded-image dimensions, present only when verified from the attachment bytes. */ + viewport?: { width: number; height: number }; + }; + error?: string; + issue?: NodeSlideEvidenceCaptureIssue; + startedAt: number; + completedAt: number; +} + +export interface NodeSlideWebEvidenceTarget { + sourceId: string; + url: string; +} + +export interface NodeSlideMappedWebEvidenceCapture { + sourceId: string; + sourceIndex: number; + requestedUrl: string; + capture: NodeSlideCapturedWebEvidence; +} + +export interface NodeSlideSourceSnapshotPdf { + bytes: Uint8Array; + contentDigest: string; + box: { x: number; y: number; w: number; h: number; page: 1 }; + viewport: { width: 612; height: 792 }; +} + +/** + * Produces an owner-only PDF from the exact search-provider record already retained by NodeSlide. + * It is deliberately labeled as a source snapshot, never as a screenshot of the source webpage. + */ +export function createNodeSlideSourceSnapshotPdf(args: { + title: string; + url: string; + excerpt: string; + provider: string; + retrievedAt: number; +}): NodeSlideSourceSnapshotPdf { + const url = safePublicCaptureUrl(args.url); + const title = cleanPdfText(args.title, 180) || new URL(url).hostname; + const excerpt = cleanPdfText(args.excerpt, 2_400) || 'No excerpt was returned.'; + const provider = cleanPdfText(args.provider, 80) || 'search provider'; + const retrievedAt = new Date(args.retrievedAt).toISOString(); + const excerptLines = wrapPdfText(excerpt, 82).slice(0, 24); + const lines = [ + 'NodeSlide evidence snapshot', + `Title: ${title}`, + ...wrapPdfText(`URL: ${url}`, 82).slice(0, 4), + `Retrieved: ${retrievedAt}`, + `Search provider: ${provider}`, + '', + 'Bound source excerpt:', + ...excerptLines, + ]; + const excerptLabelIndex = lines.indexOf('Bound source excerpt:'); + const excerptRegionTop = pdfTextBaseline(excerptLabelIndex) + 14; + const excerptRegionBottom = pdfTextBaseline(lines.length - 1) - 6; + const excerptRegionHeight = excerptRegionTop - excerptRegionBottom; + const content = [ + 'q', + '0.96 g', + `48 ${excerptRegionBottom} 516 ${excerptRegionHeight} re f`, + '0.72 G', + `48 ${excerptRegionBottom} 516 ${excerptRegionHeight} re S`, + 'Q', + 'BT', + '/F1 11 Tf', + '54 738 Td', + ...lines.flatMap((line, index) => [ + ...(index === 0 ? ['/F1 16 Tf'] : index === 1 ? ['/F1 11 Tf'] : []), + `(${escapePdfString(line)}) Tj`, + `0 -${index === 0 ? 28 : 18} Td`, + ]), + 'ET', + ].join('\n'); + const objects = [ + '<< /Type /Catalog /Pages 2 0 R >>', + '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + `<< /Length ${content.length} >>\nstream\n${content}\nendstream`, + '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>', + ]; + let pdf = '%PDF-1.4\n%NodeSlide\n'; + const offsets: number[] = []; + for (const [index, object] of objects.entries()) { + offsets.push(pdf.length); + pdf += `${index + 1} 0 obj\n${object}\nendobj\n`; + } + const xrefOffset = pdf.length; + pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n`; + pdf += offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n \n`).join(''); + pdf += `trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF\n`; + const bytes = new TextEncoder().encode(pdf); + return { + bytes, + contentDigest: nodeslideContentDigest(bytes), + box: { + x: 48 / 612, + y: (792 - excerptRegionTop) / 792, + w: 516 / 612, + h: excerptRegionHeight / 792, + page: 1, + }, + viewport: { width: 612, height: 792 }, + }; +} + +function pdfTextBaseline(index: number): number { + return index === 0 ? 738 : 738 - 28 - (index - 1) * 18; +} + +interface FirecrawlResponse { + success?: boolean; + data?: { + markdown?: string; + screenshot?: string; + metadata?: { title?: string; sourceURL?: string }; + }; +} + +class NodeSlideCaptureIssueError extends Error { + constructor(readonly issue: NodeSlideEvidenceCaptureIssue) { + super(issue.message); + this.name = 'NodeSlideCaptureIssueError'; + } +} + +/** + * Captures targets without ever dropping or reindexing a source. Invalid URLs resolve to a typed + * failure at their original index, so downstream custody records can bind by sourceId deterministically. + */ +export async function captureNodeSlideWebEvidenceBatch(args: { + targets: readonly NodeSlideWebEvidenceTarget[]; + apiKey: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; + now?: () => number; +}): Promise { + return Promise.all( + args.targets.map(async (target, sourceIndex) => ({ + sourceId: target.sourceId, + sourceIndex, + requestedUrl: redactCaptureUrl(target.url), + capture: await captureNodeSlideWebEvidence({ + url: target.url, + apiKey: args.apiKey, + ...(args.fetchImpl ? { fetchImpl: args.fetchImpl } : {}), + ...(args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {}), + ...(args.now ? { now: args.now } : {}), + }), + })), + ); +} + +export async function captureNodeSlideWebEvidence(args: { + url: string; + apiKey: string; + fetchImpl?: typeof fetch; + timeoutMs?: number; + now?: () => number; +}): Promise { + const now = args.now ?? Date.now; + const startedAt = now(); + const fetchImpl = args.fetchImpl ?? fetch; + let url: string; + try { + url = safePublicCaptureUrl(args.url); + } catch (error) { + const message = error instanceof Error ? error.message : 'Visual evidence source is invalid.'; + const code: NodeSlideEvidenceCaptureIssueCode = message.includes('private network') + ? 'source_url_blocked' + : 'source_url_invalid'; + return failedCapture( + redactCaptureUrl(args.url), + issue(code, 'source', message), + startedAt, + now(), + ); + } + const apiKey = args.apiKey.trim(); + if (!apiKey) { + return failedCapture( + url, + issue( + 'configuration_missing', + 'configuration', + 'Visual capture is not configured on this deployment.', + ), + startedAt, + now(), + ); + } + const controller = new AbortController(); + const timeout = setTimeout( + () => controller.abort(), + Math.max(1_000, Math.min(30_000, args.timeoutMs ?? NODESLIDE_CAPTURE_TIMEOUT_MS)), + ); + try { + const response = await fetchImpl(FIRECRAWL_ENDPOINT, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + url, + formats: ['markdown', 'screenshot'], + onlyMainContent: true, + }), + signal: controller.signal, + }); + if (!response.ok) { + return failedCapture( + url, + issue( + 'provider_http_error', + 'provider', + `Visual capture provider returned HTTP ${response.status}.`, + ), + startedAt, + now(), + ); + } + const payload = await readBoundedJsonResponse(response, NODESLIDE_CAPTURE_MAX_PROVIDER_BYTES); + if (!payload.success || !payload.data) { + return failedCapture( + url, + issue( + 'provider_evidence_missing', + 'provider', + 'Visual capture provider returned no evidence.', + ), + startedAt, + now(), + ); + } + const markdown = cleanCaptureText(payload.data.markdown ?? '', 120_000); + const title = cleanCaptureText(payload.data.metadata?.title ?? new URL(url).hostname, 180); + let screenshot: NodeSlideCapturedWebEvidence['screenshot']; + let degradation: NodeSlideEvidenceCaptureIssue | undefined; + if (payload.data.screenshot) { + let screenshotUrl: string; + try { + screenshotUrl = safeHttpsUrl(payload.data.screenshot); + } catch { + throw new NodeSlideCaptureIssueError( + issue( + 'screenshot_url_invalid', + 'screenshot', + 'Visual attachment URL was invalid or did not use HTTPS.', + ), + ); + } + const screenshotResponse = await fetchImpl(screenshotUrl, { signal: controller.signal }); + if (!screenshotResponse.ok) { + degradation = issue( + 'screenshot_http_error', + 'screenshot', + `Visual attachment provider returned HTTP ${screenshotResponse.status}.`, + ); + } else { + const bytes = await readBoundedResponseBytes( + screenshotResponse, + NODESLIDE_CAPTURE_MAX_SCREENSHOT_BYTES, + issue( + 'screenshot_too_large', + 'screenshot', + 'Visual capture exceeded the screenshot size limit.', + ), + ); + if (bytes.byteLength === 0) { + degradation = issue( + 'screenshot_empty', + 'screenshot', + 'Visual attachment provider returned an empty screenshot.', + ); + } else { + const viewport = readRasterImageDimensions(bytes); + screenshot = { + bytes, + mimeType: captureMimeType(screenshotResponse.headers.get('content-type')), + ...(viewport ? { viewport } : {}), + }; + } + } + } else { + degradation = issue( + 'provider_evidence_missing', + 'screenshot', + 'Visual capture provider returned text without a screenshot.', + ); + } + const completedAt = now(); + return { + ok: true, + status: degradation ? 'degraded' : 'captured', + provider: 'firecrawl', + url, + title, + markdown, + contentDigest: nodeslideContentDigest( + screenshot?.bytes ?? new TextEncoder().encode(markdown || `${url}\n${title}`), + ), + ...(screenshot ? { screenshot } : {}), + ...(degradation ? { issue: degradation } : {}), + startedAt, + completedAt, + }; + } catch (error) { + if (error instanceof NodeSlideCaptureIssueError) { + return failedCapture(url, error.issue, startedAt, now()); + } + const captureIssue = + error instanceof Error && error.name === 'AbortError' + ? issue('timeout', 'network', 'Visual capture timed out.') + : issue('network_error', 'network', 'Visual capture failed safely.'); + return failedCapture(url, captureIssue, startedAt, now()); + } finally { + clearTimeout(timeout); + } +} + +async function readBoundedJsonResponse( + response: Response, + maxBytes: number, +): Promise { + const bytes = await readBoundedResponseBytes( + response, + maxBytes, + issue( + 'provider_response_too_large', + 'provider', + 'Visual capture provider response exceeded the size limit.', + ), + ); + try { + const parsed: unknown = JSON.parse(new TextDecoder().decode(bytes)); + if (!isFirecrawlResponse(parsed)) throw new Error('Unexpected response shape.'); + return parsed; + } catch { + throw new NodeSlideCaptureIssueError( + issue( + 'provider_response_invalid', + 'provider', + 'Visual capture provider returned invalid JSON.', + ), + ); + } +} + +async function readBoundedResponseBytes( + response: Response, + maxBytes: number, + tooLargeIssue: NodeSlideEvidenceCaptureIssue, +): Promise { + const declaredLength = Number(response.headers.get('content-length') ?? 0); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + try { + await response.body?.cancel(); + } catch { + // The typed size failure is authoritative even if the transport cannot be cancelled. + } + throw new NodeSlideCaptureIssueError(tooLargeIssue); + } + + const reader = response.body?.getReader(); + if (!reader) return new Uint8Array(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + if (!value?.byteLength) continue; + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + try { + await reader.cancel(); + } catch { + // The typed size failure is authoritative even if cancellation itself fails. + } + throw new NodeSlideCaptureIssueError(tooLargeIssue); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +function isFirecrawlResponse(value: unknown): value is FirecrawlResponse { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + const data = Reflect.get(value, 'data'); + return data === undefined || (typeof data === 'object' && data !== null && !Array.isArray(data)); +} + +export function safePublicCaptureUrl(value: string): string { + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + throw new Error('Visual evidence capture requires an HTTP or HTTPS source.'); + } + const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + if ( + host === 'localhost' || + host.endsWith('.localhost') || + host === '0.0.0.0' || + host === '::1' || + (host.includes(':') && + (host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd'))) || + /^::ffff:(127\.|10\.|192\.168\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host) || + /^127\./.test(host) || + /^10\./.test(host) || + /^192\.168\./.test(host) || + /^169\.254\./.test(host) || + /^172\.(1[6-9]|2\d|3[01])\./.test(host) + ) { + throw new Error('Visual evidence capture cannot access private network addresses.'); + } + parsed.hash = ''; + return parsed.toString(); +} + +function safeHttpsUrl(value: string): string { + const parsed = new URL(safePublicCaptureUrl(value)); + if (parsed.protocol !== 'https:') throw new Error('Visual attachment URL must use HTTPS.'); + return parsed.toString(); +} + +function redactCaptureUrl(value: string): string { + try { + const parsed = new URL(value); + parsed.username = ''; + parsed.password = ''; + parsed.hash = ''; + return parsed.toString(); + } catch { + return 'invalid-source-url'; + } +} + +function cleanCaptureText(value: string, max: number): string { + return value.split('\0').join('').replace(/\r\n?/g, '\n').trim().slice(0, max); +} + +function cleanPdfText(value: string, max: number): string { + return value + .normalize('NFKD') + .replace(/[^\x20-\x7E\n]/g, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, max); +} + +function wrapPdfText(value: string, width: number): string[] { + const words = value.split(/\s+/).filter(Boolean); + const lines: string[] = []; + let line = ''; + for (const word of words) { + if (word.length > width) { + if (line) lines.push(line); + for (let offset = 0; offset < word.length; offset += width) { + lines.push(word.slice(offset, offset + width)); + } + line = ''; + } else if (!line) { + line = word; + } else if (line.length + word.length + 1 <= width) { + line += ` ${word}`; + } else { + lines.push(line); + line = word; + } + } + if (line) lines.push(line); + return lines; +} + +function escapePdfString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); +} + +function captureMimeType(value: string | null): 'image/png' | 'image/jpeg' | 'image/webp' { + const normalized = value?.split(';')[0]?.trim().toLowerCase(); + if (normalized === 'image/jpeg' || normalized === 'image/webp') return normalized; + return 'image/png'; +} + +/** Reads encoded raster dimensions without decoding or trusting provider metadata. */ +export function readRasterImageDimensions( + bytes: Uint8Array, +): { width: number; height: number } | undefined { + if ( + bytes.length >= 24 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[12] === 0x49 && + bytes[13] === 0x48 && + bytes[14] === 0x44 && + bytes[15] === 0x52 + ) { + return validRasterDimensions(readUint32Be(bytes, 16), readUint32Be(bytes, 20)); + } + if (bytes.length >= 10 && bytes[0] === 0xff && bytes[1] === 0xd8) { + let offset = 2; + while (offset + 8 < bytes.length) { + if (bytes[offset] !== 0xff) { + offset += 1; + continue; + } + while (offset < bytes.length && bytes[offset] === 0xff) offset += 1; + const marker = bytes[offset++]; + if (marker === undefined || marker === 0xd9 || marker === 0xda) break; + if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) continue; + if (offset + 1 >= bytes.length) break; + const segmentLength = readUint16Be(bytes, offset); + if (segmentLength < 2 || offset + segmentLength > bytes.length) break; + if (JPEG_START_OF_FRAME_MARKERS.has(marker) && segmentLength >= 7) { + return validRasterDimensions( + readUint16Be(bytes, offset + 5), + readUint16Be(bytes, offset + 3), + ); + } + offset += segmentLength; + } + } + if (bytes.length >= 30 && asciiAt(bytes, 0, 'RIFF') && asciiAt(bytes, 8, 'WEBP')) { + if (asciiAt(bytes, 12, 'VP8X')) { + return validRasterDimensions(readUint24Le(bytes, 24) + 1, readUint24Le(bytes, 27) + 1); + } + if (asciiAt(bytes, 12, 'VP8L') && bytes[20] === 0x2f) { + const b21 = bytes[21] ?? 0; + const b22 = bytes[22] ?? 0; + const b23 = bytes[23] ?? 0; + const b24 = bytes[24] ?? 0; + return validRasterDimensions( + 1 + b21 + ((b22 & 0x3f) << 8), + 1 + (b22 >> 6) + (b23 << 2) + ((b24 & 0x0f) << 10), + ); + } + if ( + asciiAt(bytes, 12, 'VP8 ') && + bytes[23] === 0x9d && + bytes[24] === 0x01 && + bytes[25] === 0x2a + ) { + return validRasterDimensions( + (((bytes[27] ?? 0) << 8) | (bytes[26] ?? 0)) & 0x3fff, + (((bytes[29] ?? 0) << 8) | (bytes[28] ?? 0)) & 0x3fff, + ); + } + } + return undefined; +} + +const JPEG_START_OF_FRAME_MARKERS = new Set([ + 0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf, +]); + +function validRasterDimensions(width: number, height: number) { + return Number.isInteger(width) && + Number.isInteger(height) && + width > 0 && + height > 0 && + width <= 100_000 && + height <= 100_000 + ? { width, height } + : undefined; +} + +function readUint16Be(bytes: Uint8Array, offset: number): number { + return ((bytes[offset] ?? 0) << 8) | (bytes[offset + 1] ?? 0); +} + +function readUint24Le(bytes: Uint8Array, offset: number): number { + return (bytes[offset] ?? 0) | ((bytes[offset + 1] ?? 0) << 8) | ((bytes[offset + 2] ?? 0) << 16); +} + +function readUint32Be(bytes: Uint8Array, offset: number): number { + return ( + (bytes[offset] ?? 0) * 0x1000000 + + ((bytes[offset + 1] ?? 0) << 16) + + ((bytes[offset + 2] ?? 0) << 8) + + (bytes[offset + 3] ?? 0) + ); +} + +function asciiAt(bytes: Uint8Array, offset: number, value: string): boolean { + return [...value].every((character, index) => bytes[offset + index] === character.charCodeAt(0)); +} + +function failedCapture( + url: string, + captureIssue: NodeSlideEvidenceCaptureIssue, + startedAt: number, + completedAt: number, +): NodeSlideCapturedWebEvidence { + return { + ok: false, + status: 'failed', + provider: 'firecrawl', + url, + title: captureHostname(url), + markdown: '', + error: captureIssue.message, + issue: captureIssue, + startedAt, + completedAt, + }; +} + +function issue( + code: NodeSlideEvidenceCaptureIssueCode, + stage: NodeSlideEvidenceCaptureIssue['stage'], + message: string, +): NodeSlideEvidenceCaptureIssue { + return { code, stage, message }; +} + +function captureHostname(url: string): string { + try { + return new URL(url).hostname || 'invalid source'; + } catch { + return 'invalid source'; + } +} diff --git a/convex/lib/nodeslideJobState.test.ts b/convex/lib/nodeslideJobState.test.ts index ef1c857..022d6cf 100644 --- a/convex/lib/nodeslideJobState.test.ts +++ b/convex/lib/nodeslideJobState.test.ts @@ -8,13 +8,17 @@ import { assertNodeSlideJobIdempotency, cancelNodeSlideJob, claimNodeSlideJobAttempt, + classifyNodeSlideJobFreshness, failNodeSlideJob, + heartbeatNodeSlideJob, isNodeSlideJobTerminal, nodeSlideJobExecutionDigest, nodeSlideJobOwnerDigest, nodeSlideJobRequestDigest, + pauseNodeSlideJob, publicNodeSlideJob, resolveNodeSlideReviewJob, + resumeNodeSlideJob, retryNodeSlideJob, } from './nodeslideJobState'; @@ -78,10 +82,52 @@ describe('NodeSlide durable job state', () => { expect(late.resultDeckId).toBeUndefined(); }); + it('pauses cooperatively, fences late completion, and resumes the same running attempt', () => { + const running = advanceNodeSlideJob( + claimNodeSlideJobAttempt(job(), 2_000), + { status: 'running', phase: 'generating', progress: 45 }, + 3_000, + ); + const paused = pauseNodeSlideJob(running, 4_000); + const late = advanceNodeSlideJob( + paused, + { status: 'succeeded', phase: 'complete', progress: 100, resultDeckId: 'deck_late' }, + 5_000, + ); + + expect(paused).toMatchObject({ status: 'paused', phase: 'paused', attempt: 1, progress: 45 }); + expect(pauseNodeSlideJob(paused, 4_500)).toBe(paused); + expect(late).toBe(paused); + expect(failNodeSlideJob(paused, 'Late workflow failure.', 5_000)).toBe(paused); + + const resumed = resumeNodeSlideJob(paused, 6_000, 'running'); + expect(resumed).toMatchObject({ + id: running.id, + status: 'running', + phase: 'planning', + attempt: 1, + progress: 45, + }); + }); + + it('keeps cancellation terminal from paused state', () => { + const paused = pauseNodeSlideJob(claimNodeSlideJobAttempt(job(), 2_000), 3_000); + const cancelled = cancelNodeSlideJob(paused, 4_000); + expect(cancelled).toMatchObject({ status: 'cancelled', phase: 'cancelled' }); + expect( + advanceNodeSlideJob( + cancelled, + { status: 'succeeded', phase: 'complete', progress: 100 }, + 5_000, + ), + ).toBe(cancelled); + }); + it('bounds retries and preserves the stable job id across attempts', () => { let current = job({ status: 'failed', phase: 'failed' }); for (let attempt = 1; attempt <= NODESLIDE_JOB_MAX_ATTEMPTS; attempt += 1) { current = retryNodeSlideJob(current, attempt * 10); + expect(current.status).toBe('retrying'); current = claimNodeSlideJobAttempt(current, attempt * 10 + 1); current = advanceNodeSlideJob( current, @@ -94,6 +140,23 @@ describe('NodeSlide durable job state', () => { expect(() => retryNodeSlideJob(current, 100)).toThrow(/retry limit/i); }); + it('persists heartbeats and classifies an overdue active job as stalled without terminating it', () => { + const running = claimNodeSlideJobAttempt(job(), 2_000); + const heartbeat = heartbeatNodeSlideJob(running, 3_000); + expect(heartbeat.updatedAt).toBe(3_000); + expect(classifyNodeSlideJobFreshness(heartbeat, 3_099, 100)).toEqual({ + freshness: 'fresh', + heartbeatAt: 3_000, + }); + expect(classifyNodeSlideJobFreshness(heartbeat, 3_100, 100)).toEqual({ + freshness: 'stalled', + heartbeatAt: 3_000, + stalledSince: 3_100, + }); + expect(heartbeat.status).toBe('running'); + expect(heartbeat.completedAt).toBeUndefined(); + }); + it('bounds workflow failures so callback persistence cannot strand a running job', () => { const failed = failNodeSlideJob( claimNodeSlideJobAttempt(job(), 2_000), diff --git a/convex/lib/nodeslideJobState.ts b/convex/lib/nodeslideJobState.ts index 88f096a..c565302 100644 --- a/convex/lib/nodeslideJobState.ts +++ b/convex/lib/nodeslideJobState.ts @@ -5,6 +5,8 @@ export const NODESLIDE_JOB_MAX_ATTEMPTS = 3; export const NODESLIDE_JOB_STATUSES = [ 'queued', 'running', + 'retrying', + 'paused', 'awaiting_review', 'succeeded', 'failed', @@ -19,6 +21,8 @@ export const NODESLIDE_JOB_PHASES = [ 'generating', 'persisting', 'validating', + 'retrying', + 'paused', 'awaiting_review', 'complete', 'failed', @@ -30,6 +34,14 @@ export const NODESLIDE_JOB_PHASES = [ export type NodeSlideJobStatus = (typeof NODESLIDE_JOB_STATUSES)[number]; export type NodeSlideJobPhase = (typeof NODESLIDE_JOB_PHASES)[number]; export type NodeSlideJobKind = 'create_deck' | 'edit_proposal'; +export type NodeSlideJobFreshness = 'fresh' | 'stalled' | 'paused' | 'settled'; + +/** + * A heartbeat older than this is surfaced as stalled. It is deliberately a + * classification, not a terminal transition: an owner can still resume, + * retry, or cancel the durable workflow without fabricating completion. + */ +export const NODESLIDE_JOB_STALL_AFTER_MS = 120_000; export interface NodeSlideJobRecord { id: string; @@ -92,6 +104,9 @@ export interface PublicNodeSlideJob { createdAt: number; updatedAt: number; completedAt?: number; + heartbeatAt: number; + freshness: NodeSlideJobFreshness; + stalledSince?: number; } const TERMINAL = new Set([ @@ -132,7 +147,9 @@ export function assertNodeSlideJobIdempotency( } export function claimNodeSlideJobAttempt(job: NodeSlideJobRecord, now: number): NodeSlideJobRecord { - if (TERMINAL.has(job.status) || job.status === 'awaiting_review') return job; + if (TERMINAL.has(job.status) || job.status === 'awaiting_review' || job.status === 'paused') { + return job; + } // @convex-dev/workflow may retry the same action invocation after a transient // error. Re-entering an already-running durable job must reuse its fenced // attempt/lease; only an explicit failed -> retry -> queued cycle may advance it. @@ -218,7 +235,12 @@ export function advanceNodeSlideJob( now: number, ): NodeSlideJobRecord { if (TERMINAL.has(job.status)) return job; - if (TERMINAL.has(job.status) && update.status !== job.status) return job; + // Pausing is cooperative. A provider/tool call already in flight may return, + // but its late checkpoints and completion are fenced until an explicit + // owner-authorized resume restarts the durable workflow. + if (job.status === 'paused' && update.status !== 'paused' && update.status !== 'cancelled') { + return job; + } const progress = boundedProgress(update.progress); if (progress < job.progress) throw new Error('NodeSlide job progress cannot move backwards.'); const status = update.status ?? job.status; @@ -261,12 +283,74 @@ export function cancelNodeSlideJob(job: NodeSlideJobRecord, now: number): NodeSl ); } +export function pauseNodeSlideJob(job: NodeSlideJobRecord, now: number): NodeSlideJobRecord { + if (job.status === 'paused' || TERMINAL.has(job.status) || job.status === 'awaiting_review') { + return job; + } + return advanceNodeSlideJob( + job, + { + status: 'paused', + phase: 'paused', + progress: job.progress, + }, + now, + ); +} + +export function resumeNodeSlideJob( + job: NodeSlideJobRecord, + now: number, + resumeTo: 'queued' | 'running' | 'retrying' = job.attempt === 0 ? 'queued' : 'running', +): NodeSlideJobRecord { + if (job.status !== 'paused') { + throw new Error('Only a paused NodeSlide job can be resumed.'); + } + const { error: _error, completedAt: _completedAt, ...resumable } = job; + return { + ...resumable, + status: resumeTo, + phase: resumeTo === 'running' ? 'planning' : resumeTo, + updatedAt: now, + }; +} + +export function heartbeatNodeSlideJob(job: NodeSlideJobRecord, now: number): NodeSlideJobRecord { + if ( + TERMINAL.has(job.status) || + job.status === 'awaiting_review' || + job.status === 'paused' || + now <= job.updatedAt + ) { + return job; + } + return { ...job, updatedAt: now }; +} + +export function classifyNodeSlideJobFreshness( + job: Pick, + now = Date.now(), + stallAfterMs = NODESLIDE_JOB_STALL_AFTER_MS, +): { freshness: NodeSlideJobFreshness; heartbeatAt: number; stalledSince?: number } { + if (job.status === 'paused') { + return { freshness: 'paused', heartbeatAt: job.updatedAt }; + } + if (TERMINAL.has(job.status) || job.status === 'awaiting_review') { + return { freshness: 'settled', heartbeatAt: job.updatedAt }; + } + const boundedStallAfterMs = Math.max(1, Math.floor(stallAfterMs)); + const stalledSince = job.updatedAt + boundedStallAfterMs; + return now >= stalledSince + ? { freshness: 'stalled', heartbeatAt: job.updatedAt, stalledSince } + : { freshness: 'fresh', heartbeatAt: job.updatedAt }; +} + export function failNodeSlideJob( job: NodeSlideJobRecord, error: string, now: number, ): NodeSlideJobRecord { - if (TERMINAL.has(job.status)) return job; + if (TERMINAL.has(job.status) || job.status === 'paused') return job; return advanceNodeSlideJob( job, { @@ -287,8 +371,8 @@ export function retryNodeSlideJob(job: NodeSlideJobRecord, now: number): NodeSli const { error: _error, completedAt: _completedAt, ...resumable } = job; return { ...resumable, - status: 'queued', - phase: 'queued', + status: 'retrying', + phase: 'retrying', updatedAt: now, }; } @@ -330,6 +414,7 @@ export function resolveNodeSlideReviewJob( } export function publicNodeSlideJob(job: NodeSlideJobRecord): PublicNodeSlideJob { + const freshness = classifyNodeSlideJobFreshness(job); return { jobId: job.id, kind: job.kind, @@ -351,6 +436,7 @@ export function publicNodeSlideJob(job: NodeSlideJobRecord): PublicNodeSlideJob createdAt: job.createdAt, updatedAt: job.updatedAt, ...(job.completedAt ? { completedAt: job.completedAt } : {}), + ...freshness, ...publicResult(job), }; } @@ -405,6 +491,12 @@ function validatePhaseStatus( if (status === 'failed' && phase !== 'failed') { throw new Error('A failed NodeSlide job must use the failed phase.'); } + if (status === 'retrying' && phase !== 'retrying') { + throw new Error('A retrying NodeSlide job must use the retrying phase.'); + } + if (status === 'paused' && phase !== 'paused') { + throw new Error('A paused NodeSlide job must use the paused phase.'); + } if (status === 'cancelled' && phase !== 'cancelled') { throw new Error('A cancelled NodeSlide job must use the cancelled phase.'); } diff --git a/convex/lib/nodeslideJobValidators.test.ts b/convex/lib/nodeslideJobValidators.test.ts index bdd806f..24196eb 100644 --- a/convex/lib/nodeslideJobValidators.test.ts +++ b/convex/lib/nodeslideJobValidators.test.ts @@ -31,11 +31,13 @@ describe('NodeSlide durable job request journal', () => { webResearchConsent: 'nodeslide_web_research_v1', }); const substitutedClock = canonicalEditRequest({ baseDeckVersion: 8 }); + const substitutedBudget = canonicalEditRequest({ maxCostUsd: 0.25 }); expect(nodeSlideJobRequestDigest(first)).not.toBe( nodeSlideJobRequestDigest(substitutedConsent), ); expect(nodeSlideJobRequestDigest(first)).not.toBe(nodeSlideJobRequestDigest(substitutedClock)); + expect(nodeSlideJobRequestDigest(first)).not.toBe(nodeSlideJobRequestDigest(substitutedBudget)); }); }); diff --git a/convex/lib/nodeslideJobValidators.ts b/convex/lib/nodeslideJobValidators.ts index e5baadc..c228357 100644 --- a/convex/lib/nodeslideJobValidators.ts +++ b/convex/lib/nodeslideJobValidators.ts @@ -47,6 +47,7 @@ export const nodeslideEditProposalJobRequestFields = { providerModel: v.optional(nodeslideAgentModelValidator), providerEffort: v.optional(nodeslideReasoningEffortValidator), providerConsent: v.optional(v.string()), + maxCostUsd: v.optional(v.number()), webResearch: v.optional(v.boolean()), webResearchConsent: v.optional(v.string()), memoryMode: v.optional(v.union(v.literal('off'), v.literal('relevant'))), @@ -98,6 +99,7 @@ export function nodeSlideEditProposalJobRequestFromArgs( ...(args.providerModel ? { providerModel: args.providerModel } : {}), ...(args.providerEffort ? { providerEffort: args.providerEffort } : {}), ...(typeof args.providerConsent === 'string' ? { providerConsent: args.providerConsent } : {}), + ...(args.maxCostUsd !== undefined ? { maxCostUsd: args.maxCostUsd } : {}), ...(args.webResearch !== undefined ? { webResearch: args.webResearch } : {}), ...(typeof args.webResearchConsent === 'string' ? { webResearchConsent: args.webResearchConsent } diff --git a/convex/lib/nodeslideLiveDeckRepl.test.ts b/convex/lib/nodeslideLiveDeckRepl.test.ts new file mode 100644 index 0000000..da51d40 --- /dev/null +++ b/convex/lib/nodeslideLiveDeckRepl.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest'; +import { + NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT, + validateNodeSlideLiveEditWithDeckRepl, +} from './nodeslideLiveDeckRepl'; +import { buildGoldenNodeSlide } from './nodeslideSeed'; + +const NOW = 1_720_000_000_000; + +describe('NodeSlide live Deck REPL validation', () => { + it('inspects, measures, and validates the exact review candidate', () => { + const snapshot = buildGoldenNodeSlide('live-repl', NOW).snapshot; + const element = snapshot.elements.find((candidate) => candidate.kind === 'text'); + expect(element).toBeDefined(); + if (!element) return; + const operation = { + op: 'replace_text' as const, + slideId: element.slideId, + elementId: element.id, + text: `${element.content ?? ''} Refined.`, + }; + + const validation = validateNodeSlideLiveEditWithDeckRepl({ + runId: 'run-live-repl', + traceId: 'trace-live-repl', + snapshot, + baseDeckVersion: snapshot.deck.version, + baseSlideVersions: Object.fromEntries( + snapshot.slides.map((slide) => [slide.id, slide.version]), + ), + baseElementVersions: Object.fromEntries( + snapshot.elements.map((candidate) => [candidate.id, candidate.version]), + ), + scope: { + kind: 'elements', + deckId: snapshot.deck.id, + slideIds: [element.slideId], + elementIds: [element.id], + operationMode: 'copy', + }, + operations: [operation], + }); + + expect(validation.status).toBe('validated'); + if (validation.status !== 'validated') return; + expect(validation.operations).toEqual([operation]); + expect(validation.inspectedSlideIds).toEqual([element.slideId]); + expect(validation.result.receipts.map((receipt) => receipt.commandType)).toEqual([ + 'inspect_deck', + 'inspect_slide', + 'measure_slide', + 'propose_patch', + ]); + expect(validation.result.proposals[0]?.operations).toEqual([operation]); + }); + + it('keeps high-cardinality candidates on the established validator path', () => { + const snapshot = buildGoldenNodeSlide('live-repl-large', NOW).snapshot; + const operations = Array.from( + { length: NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT + 1 }, + (_, index) => ({ + op: 'update_deck' as const, + properties: { title: `Deck ${index}` }, + }), + ); + const validation = validateNodeSlideLiveEditWithDeckRepl({ + runId: 'run-live-repl-large', + traceId: 'trace-live-repl-large', + snapshot, + baseDeckVersion: snapshot.deck.version, + baseSlideVersions: {}, + baseElementVersions: {}, + scope: { kind: 'deck', deckId: snapshot.deck.id, operationMode: 'unrestricted' }, + operations, + }); + + expect(validation).toMatchObject({ + status: 'skipped_high_cardinality', + operationLimit: NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT, + }); + }); + + it('preserves linked-comment validation outside the snapshot-only executor', () => { + const snapshot = buildGoldenNodeSlide('live-repl-comment', NOW).snapshot; + const element = snapshot.elements.find((candidate) => candidate.kind === 'text'); + expect(element).toBeDefined(); + if (!element) return; + const operation = { + op: 'replace_text' as const, + slideId: element.slideId, + elementId: element.id, + text: 'Comment-requested refinement.', + }; + + const validation = validateNodeSlideLiveEditWithDeckRepl({ + runId: 'run-live-repl-comment', + traceId: 'trace-live-repl-comment', + snapshot, + baseDeckVersion: snapshot.deck.version, + baseSlideVersions: { [element.slideId]: 1 }, + baseElementVersions: { [element.id]: element.version }, + scope: { + kind: 'comment', + deckId: snapshot.deck.id, + slideIds: [element.slideId], + elementIds: [element.id], + commentId: 'comment-open', + operationMode: 'copy', + }, + operations: [operation], + }); + + expect(validation).toMatchObject({ + status: 'skipped_unsupported_scope', + scopeKind: 'comment', + operations: [operation], + }); + }); +}); diff --git a/convex/lib/nodeslideLiveDeckRepl.ts b/convex/lib/nodeslideLiveDeckRepl.ts new file mode 100644 index 0000000..e99571f --- /dev/null +++ b/convex/lib/nodeslideLiveDeckRepl.ts @@ -0,0 +1,137 @@ +import type { DeckSnapshot, PatchOperation, PatchScope } from '../../shared/nodeslide'; +import { + type NodeSlideDeckReplResult, + nodeSlideSnapshotDigest, + runNodeSlideDeckRepl, +} from './nodeslideDeckRepl'; +import { nodeslideStableId } from './nodeslideIds'; + +export const NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT = 64 as const; +export const NODESLIDE_LIVE_DECK_REPL_SLIDE_LIMIT = 8 as const; + +export type NodeSlideLiveDeckReplValidation = + | { + status: 'validated'; + operations: PatchOperation[]; + inspectedSlideIds: string[]; + result: NodeSlideDeckReplResult; + } + | { + status: 'skipped_high_cardinality'; + operations: PatchOperation[]; + inspectedSlideIds: string[]; + operationLimit: typeof NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT; + } + | { + status: 'skipped_unsupported_scope'; + operations: PatchOperation[]; + inspectedSlideIds: string[]; + scopeKind: 'comment'; + }; + +/** + * Runs the exact live edit candidate through the bounded semantic executor. + * + * The REPL stays pure and non-committing: it reads an immutable snapshot, emits + * bounded inspection receipts, and returns the exact validated proposal. The + * caller still persists a review proposal through the existing owner-gated + * mutation. High-cardinality candidates retain the established validator path + * because the REPL's independent hard limit is intentionally lower. + */ +export function validateNodeSlideLiveEditWithDeckRepl(args: { + runId: string; + traceId: string; + snapshot: DeckSnapshot; + baseDeckVersion: number; + baseSlideVersions: Record; + baseElementVersions: Record; + scope: PatchScope; + operations: readonly PatchOperation[]; +}): NodeSlideLiveDeckReplValidation { + const operations = structuredClone([...args.operations]); + const inspectedSlideIds = touchedExistingSlideIds(args.snapshot, operations).slice( + 0, + NODESLIDE_LIVE_DECK_REPL_SLIDE_LIMIT, + ); + // Comments are stored alongside the workspace, not in the immutable deck + // snapshot consumed by the semantic REPL. Preserve the established + // linked-comment validator/persistence path instead of falsely rejecting a + // valid comment scope because that external anchor is absent here. + if (args.scope.kind === 'comment') { + return { + status: 'skipped_unsupported_scope', + operations, + inspectedSlideIds, + scopeKind: 'comment', + }; + } + if (operations.length > NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT) { + return { + status: 'skipped_high_cardinality', + operations, + inspectedSlideIds, + operationLimit: NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT, + }; + } + + const commands = [ + { id: 'inspect-deck', type: 'inspect_deck' as const }, + ...inspectedSlideIds.flatMap((slideId, index) => [ + { id: `inspect-slide-${index + 1}`, type: 'inspect_slide' as const, slideId }, + { id: `measure-slide-${index + 1}`, type: 'measure_slide' as const, slideId }, + ]), + { + id: 'validate-live-proposal', + type: 'propose_patch' as const, + baseDeckVersion: args.baseDeckVersion, + baseSlideVersions: structuredClone(args.baseSlideVersions), + baseElementVersions: structuredClone(args.baseElementVersions), + scope: structuredClone(args.scope), + operations, + }, + ]; + const result = runNodeSlideDeckRepl({ + sessionId: nodeslideStableId('session_live_edit', args.runId), + traceId: args.traceId, + snapshot: args.snapshot, + expectedSnapshotDigest: nodeSlideSnapshotDigest(args.snapshot), + commands, + budget: { + maxSteps: commands.length, + maxInputBytes: 128_000, + maxOutputBytes: 96_000, + maxOperations: NODESLIDE_LIVE_DECK_REPL_OPERATION_LIMIT, + maxWallTimeMs: 5_000, + }, + }); + const proposal = + result.status === 'completed' && result.proposals.length === 1 ? result.proposals[0] : null; + if (!proposal) { + const detail = result.receipts.find((receipt) => receipt.status === 'error')?.summary; + throw new Error(detail ?? `Bounded Deck REPL stopped before review: ${result.terminalReason}.`); + } + return { + status: 'validated', + operations: structuredClone(proposal.operations), + inspectedSlideIds, + result, + }; +} + +function touchedExistingSlideIds( + snapshot: DeckSnapshot, + operations: readonly PatchOperation[], +): string[] { + const existing = new Set(snapshot.slides.map((slide) => slide.id)); + const touched = new Set(); + for (const operation of operations) { + const slideId = + 'slideId' in operation + ? operation.slideId + : operation.op === 'add_slide' + ? operation.slide.id + : null; + if (slideId && existing.has(slideId)) touched.add(slideId); + } + return snapshot.deck.slideOrder.filter((slideId) => touched.has(slideId)); +} diff --git a/convex/lib/nodeslideReviewUi.test.tsx b/convex/lib/nodeslideReviewUi.test.tsx index f9a777c..008c909 100644 --- a/convex/lib/nodeslideReviewUi.test.tsx +++ b/convex/lib/nodeslideReviewUi.test.tsx @@ -113,11 +113,12 @@ describe('NodeSlide AI review inspector', () => { it('recommends the live Nebius GLM route with provider-native effort controls', () => { const markup = renderAi({ onApprovalModeChange: () => undefined }); expect(markup).toContain('External model: on · Nebius · GLM 5.2'); - expect(markup).toContain('data-testid="ai-approval-summary"'); - expect(markup).toContain('Review before applying'); + expect(markup).toContain('data-testid="ai-turbo-control"'); + expect(markup).toContain('data-testid="ai-turbo-toggle"'); expect(markup).toMatch( - /data-testid="ai-approval-summary"[^>]*aria-expanded="false"[^>]*aria-controls="nodeslide-ai-advanced-controls"/, + /]*role="switch")(?=[^>]*aria-checked="false")(?=[^>]*data-testid="ai-turbo-toggle")[^>]*>/, ); + expect(markup).toContain('Validated edits that pass Deck CI auto-apply'); expect(markup).not.toContain('data-testid="ai-provider-external"'); expect(markup).toContain('Consent required'); expect(markup).toContain('Allow this session'); @@ -126,7 +127,7 @@ describe('NodeSlide AI review inspector', () => { ); expect(markup).toContain('12 hours'); expect(markup).toContain('64 proposals'); - expect(markup).toContain('8 non-destructive operations each'); + expect(markup).toContain('up to 8 non-destructive operations per proposal'); expect(markup).toContain('data-testid="ai-model-select"'); expect(markup).toContain('data-testid="ai-effort-select"'); expect(markup).toContain(''); @@ -193,9 +194,11 @@ describe('NodeSlide AI review inspector', () => { approvalBusy: true, }); - expect(markup).toContain('aria-label="Auto-apply safe edits"'); + expect(markup).toMatch( + /]*role="switch")(?=[^>]*aria-checked="true")(?=[^>]*data-testid="ai-turbo-toggle")(?=[^>]*disabled="")[^>]*>/, + ); expect(markup).toMatch(/]*data-testid="ai-submit")(?=[^>]*disabled="")[^>]*>/); - expect(markup).toContain('Updating change handling'); + expect(markup).toContain('Updating session authority'); }); it('shows extended effort levels only for models whose provider exposes them', () => { diff --git a/convex/lib/nodeslideSeed.test.ts b/convex/lib/nodeslideSeed.test.ts index 805c42b..a7410dc 100644 --- a/convex/lib/nodeslideSeed.test.ts +++ b/convex/lib/nodeslideSeed.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { applyDeckPatch } from '../../shared/nodeslidePatch'; import { validateSnapshot } from '../../src/domains/nodeslide/slidelang/validation'; import { buildBriefNodeSlide, @@ -161,6 +162,172 @@ describe('NodeSlide seed', () => { ]); }); + it('preserves a named company, investor, audience, topic, outcome, and count in fallback', () => { + const brief = { + prompt: + 'Create a five-slide investor deck for HelioForge to present to Maya Chen at Northstar Capital, covering HelioForge\u2019s grid-storage problem, product approach, customer traction, business model, and Series A use of funds.', + audience: 'Maya Chen and Northstar Capital\u2019s investment committee', + purpose: 'Earn a diligence meeting for HelioForge\u2019s Series A', + successCriteria: ['Exactly 5 slides in the requested narrative'], + }; + + const spec = coerceBriefSpec(null, 'HelioForge Series A', brief); + + expect(spec.slides).toHaveLength(5); + expect(spec.narrative).toEqual([ + `Topic: ${brief.prompt}`, + `Audience: ${brief.audience}`, + `Desired outcome: ${brief.purpose}`, + ]); + expect(spec.slides.map((slide) => slide.title)).toEqual([ + 'HelioForge\u2019s grid-storage problem', + 'Product approach', + 'Customer traction', + 'Business model', + 'Series A use of funds', + ]); + for (const slide of spec.slides) { + expect(slide.body).toContain('HelioForge'); + expect(slide.body).toContain('Maya Chen at Northstar Capital'); + expect(slide.bullets).toEqual([ + `Audience: ${brief.audience}`, + `Desired outcome: ${brief.purpose}`, + 'Evidence: use only the brief or attached sources', + ]); + } + }); + + it('keeps a vague deterministic fallback conservative and free of invented specifics', () => { + const spec = deterministicBriefSpec('', { + prompt: 'Make a short deck.', + audience: '', + purpose: '', + successCriteria: [], + }); + + expect(spec.slides).toHaveLength(7); + expect(spec.narrative).toEqual([ + 'Topic: Make a short deck.', + 'Audience: not specified in the brief.', + 'Desired outcome: not specified in the brief.', + ]); + expect(spec.slides.map((slide) => slide.title)).toContain('The moment to solve'); + expect(JSON.stringify(spec)).not.toMatch(/\b(?:revenue|customers|market share|funding)\b/iu); + expect(spec.slides.some((slide) => slide.chart)).toBe(false); + expect(spec.slides.some((slide) => slide.formula)).toBe(false); + expect(spec.slides.some((slide) => slide.image)).toBe(false); + }); + + it('turns an explicit dogfood storyboard into claim-led fallback copy without decorative data', () => { + const spec = deterministicBriefSpec('NodeSlide dogfood', { + prompt: + 'Create exactly six slides. Slide 1 defines the communication strategy. Slide 2 shows the evidence ledger. Slide 3 turns the narrative into a storyboard. Slide 4 explains editable visual composition with a native workflow diagram. Slide 5 shows the critic and bounded repair loop. Slide 6 proves the recorded browser journey and export. Do not invent data.', + audience: 'Product and design leadership', + purpose: 'Adopt the release gate', + successCriteria: ['Every object remains editable'], + }); + + expect(spec.slides).toHaveLength(6); + expect(spec.slides[0]?.body).toContain('communication job'); + expect(spec.slides[1]?.body).toContain('evidence as a ledger'); + expect(spec.slides[4]?.body).toContain('bounded repair instructions'); + expect(spec.slides[5]?.body).toContain('browser journey'); + expect(spec.slides[3]?.diagram?.nodes).toHaveLength(3); + expect(spec.slides[0]?.diagram).toBeUndefined(); + expect(spec.slides[3]?.bullets).toEqual([ + 'Strategy + evidence', + 'Story + composition', + 'Critique + accept', + ]); + expect(spec.slides.some((slide) => slide.chart)).toBe(false); + }); + + it('keeps the recorded-proof acceptance headline exportable', () => { + const brief = { + prompt: + 'Create exactly six concise slides. Slide 1 defines the communication strategy. Slide 2 shows the evidence ledger. Slide 3 turns the narrative into a storyboard. Slide 4 explains editable visual composition with a native workflow diagram. Slide 5 shows the critic and bounded repair loop. Slide 6 proves the recorded browser journey and export.', + audience: 'Product and design leadership', + purpose: 'Adopt the release gate', + successCriteria: ['Exactly 6 slides', 'Every object remains editable'], + }; + const built = buildBriefNodeSlide({ + deckId: 'deck-dogfood-export', + projectId: 'project-dogfood-export', + title: 'NodeSlide dogfood quality system', + brief, + themeId: 'editorial-signal', + now: 1_000, + }); + const slide = built.snapshot.slides[5]; + const headline = built.snapshot.elements.find( + (element) => element.slideId === slide?.id && element.role === 'headline', + ); + if (!slide || !headline) throw new Error('Missing dogfood close headline.'); + const candidate = applyDeckPatch( + built.snapshot, + { + baseDeckVersion: 1, + scope: { + kind: 'elements', + deckId: built.snapshot.deck.id, + slideIds: [slide.id], + elementIds: [headline.id], + operationMode: 'copy', + }, + operations: [ + { + op: 'replace_text', + slideId: slide.id, + elementId: headline.id, + text: 'Adopt the quality gate and require recorded proof for every release.', + }, + ], + }, + 1_001, + ); + const validation = validateSnapshot(candidate.snapshot); + expect( + validation.publishOk, + JSON.stringify({ + issues: validation.issues, + elements: candidate.snapshot.elements + .filter((element) => element.slideId === slide.id) + .map((element) => ({ + id: element.id, + role: element.role, + kind: element.kind, + bbox: element.bbox, + })), + }), + ).toBe(true); + }); + + it('keeps server validation aligned with the export collision gate', () => { + const snapshot = structuredClone(buildGoldenNodeSlide('validation-parity', 1_000).snapshot); + const slide = snapshot.slides[0]; + const body = snapshot.elements.find( + (element) => element.slideId === slide?.id && element.role === 'body', + ); + const bullet = snapshot.elements.find( + (element) => element.slideId === slide?.id && element.role === 'bullet', + ); + if (!slide || !body || !bullet) throw new Error('Missing collision parity fixture.'); + bullet.bbox = { + ...bullet.bbox, + x: body.bbox.x, + y: body.bbox.y + body.bbox.height - bullet.bbox.height * 0.33, + }; + + const exportIssues = validateSnapshot(snapshot).issues.filter( + (issue) => issue.code === 'collision' && issue.severity === 'error', + ); + const serverIssues = validateNodeSlideSnapshot(snapshot, 1_001).issues.filter( + (issue) => issue.code === 'collision' && issue.severity === 'error', + ); + expect(exportIssues).toHaveLength(1); + expect(serverIssues).toHaveLength(1); + }); + it('keeps explicit slide directions and chart values in a compact deterministic fallback', () => { const brief = { prompt: diff --git a/convex/lib/nodeslideSeed.ts b/convex/lib/nodeslideSeed.ts index 98a0586..ed2a1e4 100644 --- a/convex/lib/nodeslideSeed.ts +++ b/convex/lib/nodeslideSeed.ts @@ -429,8 +429,11 @@ export function buildGoldenNodeSlide(clientSessionId: string, now: number): Node export function deterministicBriefSpec(title: string, brief: DeckBrief): NodeSlideDeckSpec { const cleanTitle = nodeslideCleanText(title, 80) || 'Untitled story'; - const audience = nodeslideCleanText(brief.audience, 120) || 'the audience'; - const purpose = nodeslideCleanText(brief.purpose, 180) || nodeslideCleanText(brief.prompt, 180); + const explicitTopic = nodeslideCleanText(brief.prompt, 260); + const explicitAudience = nodeslideCleanText(brief.audience, 120); + const explicitOutcome = nodeslideCleanText(brief.purpose, 180); + const audience = explicitAudience || 'the audience'; + const purpose = explicitOutcome || nodeslideCleanText(brief.prompt, 180); const outcome = sentenceCase(purpose || nodeslideCleanText(brief.prompt, 180)); const criteria = brief.successCriteria .map((criterion) => nodeslideCleanText(criterion, 96)) @@ -442,9 +445,11 @@ export function deterministicBriefSpec(title: string, brief: DeckBrief): NodeSli const spec: NodeSlideDeckSpec = { title: cleanTitle, narrative: [ - `Orient ${audience} around the central promise.`, - 'Move from current tension to a concrete, credible approach.', - 'Close with proof, ownership, and a specific next move.', + explicitTopic ? `Topic: ${explicitTopic}` : `Topic: ${cleanTitle}`, + explicitAudience ? `Audience: ${explicitAudience}` : 'Audience: not specified in the brief.', + explicitOutcome + ? `Desired outcome: ${explicitOutcome}` + : 'Desired outcome: not specified in the brief.', ], slides: [ { @@ -530,9 +535,15 @@ export function deterministicBriefSpec(title: string, brief: DeckBrief): NodeSli const requestedSlideCount = inferNodeSlideRequestedSlideCount(brief.prompt, ...brief.successCriteria) ?? 7; applyDeterministicBriefPrimitives(spec.slides, brief.prompt); - applyRequestedNarrativeJobs(spec.slides, brief.prompt, requestedSlideCount); + applyRequestedNarrativeJobs(spec.slides, brief, requestedSlideCount); applyExplicitSlideDirectives(spec.slides, brief.prompt, requestedSlideCount); preserveRequestedPrimitives(spec.slides, brief.prompt, requestedSlideCount); + removeUnsupportedFallbackPrimitives(spec.slides, brief.prompt); + // An explicit diagram can target a slide that originally carried a generic + // fallback formula/image. Retry after unsupported fallback primitives are + // removed so the user's requested visual wins without inventing a second + // primary primitive on the same slide. + applyRequestedDiagramPrimitive(spec.slides, brief.prompt); spec.slides = spec.slides.slice(0, requestedSlideCount); return spec; } @@ -551,20 +562,168 @@ type BriefPrimaryPrimitive = 'formula' | 'chart' | 'image'; */ function applyRequestedNarrativeJobs( slides: NodeSlidePlannedSlide[], - prompt: string, + brief: DeckBrief, slideCount: number, ): void { - const jobs = requestedNarrativeJobs(prompt, slideCount); + const jobs = requestedNarrativeJobs(brief.prompt, slideCount); if (jobs.length !== slideCount) return; + const topic = nodeslideCleanText(brief.prompt, 220); + const audience = nodeslideCleanText(brief.audience, 96); + const outcome = nodeslideCleanText(brief.purpose, 120); + for (const [index, job] of jobs.entries()) { const slide = slides[index]; if (!slide) continue; const label = narrativeJobLabel(job); slide.title = label; - slide.section = `${label.replace(/^The\s+/iu, '')} / ${String(index + 1).padStart(2, '0')}`; + slide.section = `${nodeslideCleanText(label.replace(/^The\s+/iu, ''), 50)} / ${String( + index + 1, + ).padStart(2, '0')}`; slide.headline = label; - slide.body = `Explain ${job} for the intended audience using only evidence supplied in the brief or attached sources.`; + slide.body = nodeslideCleanText( + `${narrativeJobClaim(job)}${audience ? ` For ${audience}.` : ''}${outcome ? ` This supports the requested outcome: ${outcome}.` : ''} Brief context: ${topic || job}.`, + 360, + ); + slide.bullets = [ + audience ? `Audience: ${audience}` : 'Audience: not specified in the brief', + outcome ? `Desired outcome: ${outcome}` : 'Desired outcome: not specified in the brief', + 'Evidence: use only the brief or attached sources', + ]; + } +} + +function narrativeJobClaim(job: string): string { + const normalized = job.toLowerCase(); + if (/governed|release gate|one-shot/iu.test(normalized)) { + return 'NodeSlide should compete on governed creativity: a strong story, editable native objects, durable validation, and proof from the real browser path.'; + } + if (/canva|gamma|hyperagent|benchmark/iu.test(normalized)) { + return 'Use the market as a design brief: Canva compresses branded asset work, Gamma compresses research-to-story, and NodeSlide must add governed editable execution plus policy learning.'; + } + if ( + /communication/iu.test(normalized) && + /evidence/iu.test(normalized) && + /story|storyboard/iu.test(normalized) + ) { + return 'Lock the audience and decision first, attach evidence to each material claim, then assign one claim-led job to every slide.'; + } + if (/communication|strategy|audience|purpose/iu.test(normalized)) { + return 'Start with the communication job: name the audience, decision, central takeaway, and success signal.'; + } + if (/evidence|research|source|claim/iu.test(normalized)) { + return 'Treat evidence as a ledger rather than decoration: every material claim keeps its source, revision, and status.'; + } + if (/narrative|story|storyboard|slide job/iu.test(normalized)) { + return 'Turn the story spine into one distinct narrative job per slide before visual composition begins.'; + } + if (/visual|layout|composition|design|material/iu.test(normalized)) { + return 'Let meaning choose the editable visual primitive and composition, with variety driven by the argument.'; + } + if (/critic|repair|quality|validation/iu.test(normalized)) { + return 'Convert independent critic findings into bounded repair instructions and stop at a strict iteration limit.'; + } + if (/journey|record|gif|proof|export/iu.test(normalized)) { + return 'A release is incomplete until the browser journey, export, screenshot, GIF, versions, and receipts agree.'; + } + if (/decision|next|adopt|commit|approve/iu.test(normalized)) { + return 'Ship only when evidence, a durable acceptance receipt, the recorded journey, and the editable export all agree.'; + } + return `Make ${nodeslideCleanText(job, 140)} concrete without inventing unsupported facts.`; +} + +function narrativeJobBullets(job: string): string[] { + const normalized = job.toLowerCase(); + if ( + /communication/iu.test(normalized) && + /evidence/iu.test(normalized) && + /story|storyboard/iu.test(normalized) + ) { + return ['Audience and decision', 'Claim and evidence ledger', 'One claim-led job per slide']; + } + if (/governed|release gate|one-shot/iu.test(normalized)) { + return ['Evidence-backed acceptance', 'Editable native output', 'Recorded end-to-end proof']; + } + if (/canva|gamma|hyperagent|benchmark/iu.test(normalized)) { + return [ + 'Canva: brand and asset velocity', + 'Gamma: research and story velocity', + 'NodeSlide: governed execution; HyperAgent: policy evolution', + ]; + } + if (/communication|strategy|audience|purpose/iu.test(normalized)) { + return [ + 'Name the audience and decision', + 'State the central takeaway', + 'Define the release bar', + ]; + } + if (/evidence|research|source|claim/iu.test(normalized)) { + return [ + 'Claim and source stay linked', + 'Assumptions stay explicit', + 'Revisions keep provenance', + ]; + } + if (/narrative|story|storyboard|slide job/iu.test(normalized)) { + return ['One claim per slide', 'Evidence earns its place', 'Sequence creates the decision']; + } + if (/visual|layout|composition|design|workflow|material/iu.test(normalized)) { + return ['Strategy + evidence', 'Story + composition', 'Critique + accept']; + } + if (/critic|repair|quality|validation|policy/iu.test(normalized)) { + return [ + 'Independent specialist critics', + 'Bounded repair with stop rules', + 'Versioned policy, held-out promotion', + ]; + } + if (/journey|record|gif|proof|export|receipt|version/iu.test(normalized)) { + return [ + 'Record the real browser path', + 'Bind receipt to deck and version', + 'Verify editable export and proof digest', + ]; + } + if (/checklist|decision|next|adopt|commit|approve/iu.test(normalized)) { + return [ + 'Require evidence-backed acceptance', + 'Require recorded end-to-end proof', + 'Benchmark and promote policy safely', + ]; + } + return [ + 'One distinct narrative job', + 'Editable presentation primitives', + 'No unsupported factual claims', + ]; +} + +function removeUnsupportedFallbackPrimitives( + slides: NodeSlidePlannedSlide[], + prompt: string, +): void { + const requestsFormula = requestsBriefPrimitive(prompt, BRIEF_PRIMARY_PRIMITIVE_PATTERNS.formula); + const requestsImage = requestsBriefPrimitive(prompt, BRIEF_PRIMARY_PRIMITIVE_PATTERNS.image); + for (const [index, slide] of slides.entries()) { + let next = slide; + if (slide.chart && new Set(slide.chart.values).size <= 1) { + const { chart: _chart, ...withoutChart } = next; + next = withoutChart; + } + if ( + next.formula && + !requestsFormula && + /accepted change|authorized change|proposal/iu.test(next.formula.expression) + ) { + const { formula: _formula, ...withoutFormula } = next; + next = withoutFormula; + } + if (next.image && !requestsImage && !next.image.url) { + const { image: _image, ...withoutImage } = next; + next = withoutImage; + } + slides[index] = next; } } @@ -637,6 +796,12 @@ function applyExplicitSlideDirectives( if (!subject) continue; slide.title = nodeslideCleanText(sentenceCase(subject), 80); slide.headline = sentenceCase(subject); + slide.body = nodeslideCleanText(narrativeJobClaim(subject), 360); + slide.bullets = narrativeJobBullets(subject); + if (slide.metric && /critic|repair|quality|validation|policy/iu.test(subject)) { + slide.metric = '3 gates'; + slide.metricLabel = 'content, visual, and release proof must agree'; + } } } @@ -943,21 +1108,49 @@ function requestsDiagramPrimitive(prompt: string): boolean { function applyRequestedDiagramPrimitive(slides: NodeSlidePlannedSlide[], prompt: string): boolean { if (!requestsDiagramPrimitive(prompt)) return true; - if (slides.some((slide) => slide.diagram)) return true; + const directive = explicitSlideDirectives(prompt, slides.length).find((candidate) => + /\b(?:diagram|flowchart|connector)(?:s|\s+primitives?)?\b/iu.test(candidate.instruction), + ); + const existingIndex = slides.findIndex((slide) => slide.diagram); + if (existingIndex >= 0) { + if (!directive || directive.index === existingIndex) return true; + const source = slides[existingIndex]; + const target = slides[directive.index]; + if ( + !source?.diagram || + !target || + target.chart || + target.formula || + target.image || + target.video + ) { + return false; + } + const { diagram, ...sourceWithoutDiagram } = source; + target.diagram = diagram; + slides[existingIndex] = sourceWithoutDiagram; + return true; + } const candidate = - slides.find( - (slide) => - /\b(?:workflow|approach|process|how|system)\b/iu.test(`${slide.title} ${slide.headline}`) && - !slide.chart && - !slide.formula && - !slide.image && - !slide.video, - ) ?? + (directive + ? slides[directive.index] + : slides.find( + (slide) => + /\b(?:workflow|approach|process|how|system)\b/iu.test( + `${slide.title} ${slide.headline}`, + ) && + !slide.chart && + !slide.formula && + !slide.image && + !slide.video, + )) ?? slides.find( (slide, index) => index > 0 && !slide.chart && !slide.formula && !slide.image && !slide.video, ); - if (!candidate) return false; + if (!candidate || candidate.chart || candidate.formula || candidate.image || candidate.video) { + return false; + } const nodes = candidate.bullets .map((label) => nodeslideCleanText(label, 52)) @@ -1271,13 +1464,15 @@ function buildSlide(input: { const evidenceSourceIds = input.linkedSourceIds.length > 0 ? input.linkedSourceIds : [input.sourceEvidenceId]; const primaryEvidenceSourceId = evidenceSourceIds[0] ?? input.sourceEvidenceId; - const bodyWidth = hasVisual ? 0.39 : isOpening || isClosing ? 0.66 : 0.48; + const mirrorSplit = !hasVisual && !isOpening && !isClosing && input.index % 2 === 0; + const bodyWidth = hasVisual ? 0.39 : isOpening || isClosing ? 0.66 : 0.41; + const bodyX = mirrorSplit ? 0.52 : 0.07; add( element('body', { name: 'Body copy', kind: 'text', role: 'body', - bbox: box(0.07, isOpening ? 0.48 : 0.4, bodyWidth, isOpening ? 0.17 : 0.2), + bbox: box(bodyX, isOpening ? 0.48 : 0.4, bodyWidth, isOpening ? 0.17 : 0.2), rotation: 0, content: planned.body, style: { @@ -1294,9 +1489,10 @@ function buildSlide(input: { ); const horizontalBullets = (isOpening || isClosing) && !hasVisual; - const bulletX = horizontalBullets ? 0.07 : hasVisual ? 0.07 : 0.59; - const bulletY = horizontalBullets ? 0.72 : hasVisual ? 0.62 : 0.42; - const bulletWidth = horizontalBullets ? 0.8 : hasVisual ? 0.39 : 0.33; + const bulletX = horizontalBullets ? 0.07 : hasVisual || mirrorSplit ? 0.07 : 0.57; + const bulletY = horizontalBullets ? 0.72 : hasVisual ? (isOpening ? 0.67 : 0.62) : 0.42; + const bulletWidth = horizontalBullets ? 0.8 : hasVisual ? 0.39 : mirrorSplit ? 0.36 : 0.35; + const stackedBulletStep = isOpening && hasVisual ? 0.1 : 0.12; planned.bullets.slice(0, 3).forEach((bullet, bulletIndex) => { add( element(`bullet-${bulletIndex + 1}`, { @@ -1305,7 +1501,7 @@ function buildSlide(input: { role: 'bullet', bbox: box( horizontalBullets ? bulletX + bulletIndex * 0.28 : bulletX, - horizontalBullets ? bulletY : bulletY + bulletIndex * 0.12, + horizontalBullets ? bulletY : bulletY + bulletIndex * stackedBulletStep, horizontalBullets ? 0.25 : bulletWidth, horizontalBullets ? 0.08 : 0.09, ), @@ -1375,13 +1571,13 @@ function buildSlide(input: { if (planned.diagram) { const nodes = planned.diagram.nodes.slice(0, 4); const groupId = nodeslideStableId('group', input.slideId, 'diagram'); - const gap = 0.025; - const diagramWidth = 0.39; - const connectorWidth = 0.032; + const gap = 0.01; + const diagramWidth = 0.41; + const connectorWidth = 0.018; const nodeWidth = (diagramWidth - connectorWidth * (nodes.length - 1) - gap * (nodes.length - 1)) / nodes.length; - let x = 0.53; + let x = 0.52; nodes.forEach((label, index) => { add( element(`diagram-node-${index + 1}`, { @@ -1397,10 +1593,10 @@ function buildSlide(input: { strokeWidth: 1.5, color: theme.colors.insightInk, fontFamily: theme.typography.body, - fontSize: 16, + fontSize: 14, fontWeight: 650, lineHeight: 1.15, - padding: 10, + padding: 6, radius: theme.defaultRadius, textAlign: 'center', verticalAlign: 'middle', diff --git a/convex/lib/nodeslideSourceRefresh.test.ts b/convex/lib/nodeslideSourceRefresh.test.ts new file mode 100644 index 0000000..d657087 --- /dev/null +++ b/convex/lib/nodeslideSourceRefresh.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from 'vitest'; +import type { + DeckSnapshot, + NodeSlideClaimSourceBinding, + Slide, + SlideElement, + SourceRecord, +} from '../../shared/nodeslide'; +import { + NODESLIDE_SOURCE_REFRESH_BATCH_LIMIT, + detectNodeSlideSourceChange, + nodeSlideSourceLogicalRevision, + planNodeSlideSourceRefresh, +} from './nodeslideSourceRefresh'; + +const SOURCE_A = 'source-a'; +const SOURCE_B = 'source-b'; + +describe('NodeSlide source refresh planning', () => { + it('models immutable logical revisions and deterministic no-change descriptors', () => { + const before = source(SOURCE_A); + const after = { ...before, columns: [...(before.columns ?? [])] }; + const beforeCopy = structuredClone(before); + const afterCopy = structuredClone(after); + + const first = detectNodeSlideSourceChange({ before, after }); + const second = detectNodeSlideSourceChange({ before: after, after: before }); + + expect(first).toEqual(second); + expect(first).toMatchObject({ + kind: 'unchanged', + logicalSourceId: SOURCE_A, + changedFields: [], + material: false, + affectedSourceIds: [], + digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + }); + expect(nodeSlideSourceLogicalRevision(before)).toMatchObject({ + sourceId: SOURCE_A, + revisionId: expect.stringMatching(/^source-revision:sha256:[0-9a-f]{64}$/), + digest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + }); + expect(before).toEqual(beforeCopy); + expect(after).toEqual(afterCopy); + }); + + it('isolates exact slide, element, primitive, and claim bindings from unrelated sources', () => { + const snapshot = deckSnapshot([ + element('b-direct', 'slide-b', [SOURCE_A]), + element('b-unrelated', 'slide-b', [SOURCE_B]), + element('a-claim-only', 'slide-a'), + element('a-chart', 'slide-a', [], { + chart: { + chartType: 'bar', + labels: ['Q1'], + series: [{ name: 'Revenue', values: [10] }], + sourceId: SOURCE_A, + }, + }), + element('c-unrelated', 'slide-c', [SOURCE_B]), + ]); + const claims: NodeSlideClaimSourceBinding[] = [ + claim('a-claim-only', 'slide-a', SOURCE_A, 'sha256:claim-a', 4), + claim('b-unrelated', 'slide-b', SOURCE_B, 'sha256:claim-b', 2), + ]; + + const plan = planNodeSlideSourceRefresh({ + snapshot, + before: source(SOURCE_A), + after: source(SOURCE_A, { contentDigest: 'sha256:new-content', rowCount: 20 }), + claimSourceBindings: claims, + }); + + expect(plan.change).toMatchObject({ + kind: 'updated', + changedFields: ['contentDigest', 'rowCount'], + material: true, + affectedSourceIds: [SOURCE_A], + }); + expect(plan.affectedSlides.map((slide) => slide.slideId)).toEqual(['slide-b', 'slide-a']); + expect(plan.affectedElements.map((affected) => affected.elementId)).toEqual([ + 'b-direct', + 'a-claim-only', + 'a-chart', + ]); + expect(plan.affectedClaims).toEqual([ + expect.objectContaining({ + slideId: 'slide-a', + elementId: 'a-claim-only', + matchedSourceIds: [SOURCE_A], + claimDigest: 'sha256:claim-a', + }), + ]); + expect(plan.affectedElements.find((item) => item.elementId === 'a-claim-only')).toMatchObject({ + sourceIds: [SOURCE_A], + claimDigests: ['sha256:claim-a'], + reasons: ['claim_source_binding'], + }); + expect(plan.affectedElements.find((item) => item.elementId === 'a-chart')).toMatchObject({ + sourceIds: [SOURCE_A], + reasons: ['element_source_binding'], + }); + expect(plan.affectedElements.map((affected) => affected.elementId)).not.toContain( + 'b-unrelated', + ); + expect(plan.affectedSlides.map((affected) => affected.slideId)).not.toContain('slide-c'); + }); + + it('emits deterministic batches with no more than eight proposal operations', () => { + const elements = Array.from({ length: 19 }, (_, index) => + element(`element-${index.toString().padStart(2, '0')}`, 'slide-a', [SOURCE_A]), + ); + const snapshot = deckSnapshot(elements); + const input = { + snapshot, + before: source(SOURCE_A), + after: source(SOURCE_A, { citation: 'Updated citation' }), + }; + + const first = planNodeSlideSourceRefresh(input); + const second = planNodeSlideSourceRefresh({ + ...input, + snapshot: { ...snapshot, elements: [...snapshot.elements].reverse() }, + }); + + expect(first.operationCount).toBe(19); + expect(first.batches.map((batch) => batch.operations.length)).toEqual([8, 8, 3]); + expect( + first.batches.every( + (batch) => batch.operations.length <= NODESLIDE_SOURCE_REFRESH_BATCH_LIMIT, + ), + ).toBe(true); + expect(first.digest).toBe(second.digest); + expect(first.batches).toEqual(second.batches); + expect( + first.batches.flatMap((batch) => batch.operations).map((item) => item.elementId), + ).toEqual(elements.map((item) => item.id)); + }); + + it('supports explicit replacement and supersession while rejecting ambiguous source pairs', () => { + const snapshot = deckSnapshot([ + element('old-binding', 'slide-a', [SOURCE_A]), + element('new-binding', 'slide-a', [SOURCE_B]), + ]); + const replacement = planNodeSlideSourceRefresh({ + snapshot, + before: source(SOURCE_A), + after: source(SOURCE_B, { title: 'Replacement source' }), + transitionHint: { + kind: 'replacement', + beforeSourceId: SOURCE_A, + afterSourceId: SOURCE_B, + reason: 'Publisher moved the canonical dataset', + }, + }); + + expect(replacement.change).toMatchObject({ + kind: 'replaced', + affectedSourceIds: [SOURCE_A, SOURCE_B], + reason: 'Replacement: Publisher moved the canonical dataset.', + }); + expect(replacement.affectedElements.map((item) => item.elementId)).toEqual([ + 'old-binding', + 'new-binding', + ]); + expect( + replacement.batches + .flatMap((batch) => batch.operations) + .every((operation) => operation.replacementSourceId === SOURCE_B), + ).toBe(true); + + const supersession = detectNodeSlideSourceChange({ + before: source(SOURCE_A), + after: source(SOURCE_B), + transitionHint: { + kind: 'supersession', + beforeSourceId: SOURCE_A, + afterSourceId: SOURCE_B, + }, + }); + expect(supersession.kind).toBe('superseded'); + + expect(() => + detectNodeSlideSourceChange({ before: source(SOURCE_A), after: source(SOURCE_B) }), + ).toThrow('explicit replacement or supersession hint'); + expect(() => + detectNodeSlideSourceChange({ + before: source(SOURCE_A), + after: source(SOURCE_B), + transitionHint: { + kind: 'replacement', + beforeSourceId: SOURCE_B, + afterSourceId: SOURCE_A, + }, + }), + ).toThrow('must exactly match'); + }); + + it('records timestamp-only revisions without scheduling stale-content work', () => { + const before = source(SOURCE_A); + const plan = planNodeSlideSourceRefresh({ + snapshot: deckSnapshot([element('bound', 'slide-a', [SOURCE_A])]), + before, + after: { + ...before, + retrievedAt: before.retrievedAt + 1_000, + lastRefreshedAt: (before.lastRefreshedAt ?? before.retrievedAt) + 1_000, + }, + }); + + expect(plan.change).toMatchObject({ + kind: 'updated', + changedFields: ['retrievedAt', 'lastRefreshedAt'], + material: false, + affectedSourceIds: [], + }); + expect(plan.affectedSlides).toEqual([]); + expect(plan.affectedElements).toEqual([]); + expect(plan.affectedClaims).toEqual([]); + expect(plan.batches).toEqual([]); + expect(plan.operationCount).toBe(0); + expect(plan.reason).toContain('No refresh operations were planned'); + }); + + it('plans creation/removal only for bound sources and rejects cross-deck planning', () => { + const snapshot = deckSnapshot([element('bound', 'slide-a', [SOURCE_A])]); + const removed = planNodeSlideSourceRefresh({ + snapshot, + before: source(SOURCE_A), + after: null, + }); + const unrelatedCreation = planNodeSlideSourceRefresh({ + snapshot, + before: null, + after: source(SOURCE_B), + }); + + expect(removed.change.kind).toBe('removed'); + expect(removed.affectedElements.map((item) => item.elementId)).toEqual(['bound']); + expect(unrelatedCreation.change.kind).toBe('created'); + expect(unrelatedCreation.affectedElements).toEqual([]); + expect(unrelatedCreation.batches).toEqual([]); + + expect(() => + planNodeSlideSourceRefresh({ + snapshot, + before: source(SOURCE_A, { deckId: 'another-deck' }), + after: null, + }), + ).toThrow('restricted to the snapshot deck'); + }); + + it('ignores duplicate and stale claim bindings deterministically', () => { + const snapshot = deckSnapshot([element('bound', 'slide-a')]); + const validClaim = claim('bound', 'slide-a', SOURCE_A, 'sha256:claim', 1); + const plan = planNodeSlideSourceRefresh({ + snapshot, + before: source(SOURCE_A), + after: source(SOURCE_A, { status: 'failed' }), + claimSourceBindings: [ + validClaim, + { ...validClaim }, + claim('missing-element', 'slide-a', SOURCE_A, 'sha256:stale', 2), + claim('bound', 'missing-slide', SOURCE_A, 'sha256:stale-slide', 3), + ], + }); + + expect(plan.affectedClaims).toHaveLength(1); + expect(plan.affectedClaims[0]?.claimDigest).toBe('sha256:claim'); + expect(plan.affectedElements).toHaveLength(1); + expect(plan.affectedElements[0]?.claimDigests).toEqual(['sha256:claim']); + }); +}); + +function source(id: string, overrides: Partial = {}): SourceRecord { + return { + id, + deckId: 'deck-1', + title: 'Quarterly dataset', + url: 'https://example.com/data.csv', + sourceType: 'spreadsheet', + retrievedAt: 1_700_000_000_000, + citation: 'Example Data (2026)', + format: 'csv', + contentDigest: 'sha256:old-content', + byteSize: 100, + rowCount: 10, + columns: ['quarter', 'revenue'], + provider: 'example', + retention: 'until_deleted', + status: 'ready', + lastRefreshedAt: 1_700_000_000_000, + ...overrides, + }; +} + +function claim( + elementId: string, + slideId: string, + sourceId: string, + claimDigest: string, + operationIndex: number, +): NodeSlideClaimSourceBinding { + return { + operationIndex, + operation: 'replace_text', + slideId, + elementId, + sourceIds: [sourceId], + claimDigest, + }; +} + +function deckSnapshot(elements: SlideElement[]): DeckSnapshot { + const slides = ['slide-b', 'slide-a', 'slide-c'].map((id) => + slide( + id, + elements.filter((candidate) => candidate.slideId === id).map((candidate) => candidate.id), + ), + ); + return { + deck: { + id: 'deck-1', + slideOrder: slides.map((candidate) => candidate.id), + } as DeckSnapshot['deck'], + slides, + elements, + sources: [source(SOURCE_A), source(SOURCE_B)], + }; +} + +function slide(id: string, elementOrder: string[]): Slide { + return { + id, + deckId: 'deck-1', + title: id, + background: '#ffffff', + elementOrder, + version: 1, + }; +} + +function element( + id: string, + slideId: string, + sourceIds: string[] = [], + extra: Partial = {}, +): SlideElement { + return { + id, + slideId, + name: id, + kind: extra.chart ? 'chart' : 'text', + bbox: { x: 0, y: 0, width: 0.5, height: 0.2 }, + rotation: 0, + content: id, + style: {}, + sourceIds, + locked: false, + exportCapabilities: ['web_native'], + version: 1, + ...extra, + }; +} diff --git a/convex/lib/nodeslideSourceRefresh.ts b/convex/lib/nodeslideSourceRefresh.ts new file mode 100644 index 0000000..202ab14 --- /dev/null +++ b/convex/lib/nodeslideSourceRefresh.ts @@ -0,0 +1,542 @@ +import type { + DeckSnapshot, + NodeSlideClaimSourceBinding, + SlideElement, + SourceRecord, +} from '../../shared/nodeslide'; +import { nodeslideContentDigest } from './nodeslideIds'; + +export const NODESLIDE_SOURCE_REFRESH_BATCH_LIMIT = 8 as const; + +export type NodeSlideSourceChangeKind = + | 'unchanged' + | 'created' + | 'updated' + | 'removed' + | 'replaced' + | 'superseded'; + +/** + * Explicitly relates snapshots whose persisted source IDs differ. Requiring the + * pair prevents two unrelated sources from being interpreted as one revision. + */ +export interface NodeSlideSourceTransitionHint { + kind: 'replacement' | 'supersession'; + beforeSourceId: string; + afterSourceId: string; + reason?: string; +} + +/** A content-addressed logical revision; no SourceRecord schema field is needed. */ +export interface NodeSlideSourceLogicalRevision { + sourceId: string; + deckId: string; + revisionId: string; + digest: string; + retrievedAt: number; + contentDigest?: string; +} + +export interface NodeSlideSourceChangeDescriptor { + kind: NodeSlideSourceChangeKind; + logicalSourceId: string; + beforeRevision?: NodeSlideSourceLogicalRevision; + afterRevision?: NodeSlideSourceLogicalRevision; + changedFields: Array; + /** Timestamp-only retrieval bookkeeping creates a revision but not stale deck content. */ + material: boolean; + affectedSourceIds: string[]; + digest: string; + reason: string; + transitionHint?: NodeSlideSourceTransitionHint; +} + +export interface NodeSlideAffectedSourceClaim { + operationIndex: number; + operation: NodeSlideClaimSourceBinding['operation']; + slideId: string; + elementId: string; + sourceIds: string[]; + matchedSourceIds: string[]; + claimDigest: string; + reason: string; +} + +export interface NodeSlideAffectedSourceElement { + slideId: string; + elementId: string; + sourceIds: string[]; + claimDigests: string[]; + reasons: Array<'element_source_binding' | 'claim_source_binding'>; + reason: string; +} + +export interface NodeSlideAffectedSourceSlide { + slideId: string; + elementIds: string[]; + claimDigests: string[]; + reason: string; +} + +/** A proposal work item, deliberately not a PatchOperation or an implicit write. */ +export interface NodeSlideSourceRefreshOperation { + kind: 'refresh_source_bound_element'; + slideId: string; + elementId: string; + sourceIds: string[]; + claimDigests: string[]; + replacementSourceId?: string; + reason: string; +} + +export interface NodeSlideSourceRefreshBatch { + index: number; + operations: NodeSlideSourceRefreshOperation[]; + reason: string; +} + +export interface NodeSlideSourceRefreshPlan { + change: NodeSlideSourceChangeDescriptor; + affectedSlides: NodeSlideAffectedSourceSlide[]; + affectedElements: NodeSlideAffectedSourceElement[]; + affectedClaims: NodeSlideAffectedSourceClaim[]; + batches: NodeSlideSourceRefreshBatch[]; + operationCount: number; + digest: string; + reason: string; +} + +export interface DetectNodeSlideSourceChangeArgs { + before: SourceRecord | null; + after: SourceRecord | null; + transitionHint?: NodeSlideSourceTransitionHint; +} + +export interface PlanNodeSlideSourceRefreshArgs extends DetectNodeSlideSourceChangeArgs { + snapshot: DeckSnapshot; + claimSourceBindings?: readonly NodeSlideClaimSourceBinding[]; +} + +const SOURCE_FIELDS = [ + 'id', + 'deckId', + 'title', + 'url', + 'sourceType', + 'retrievedAt', + 'citation', + 'license', + 'format', + 'contentDigest', + 'byteSize', + 'rowCount', + 'columns', + 'provider', + 'retention', + 'status', + 'lastRefreshedAt', +] as const satisfies readonly (keyof SourceRecord)[]; + +const BOOKKEEPING_FIELDS = new Set(['retrievedAt', 'lastRefreshedAt']); + +export function nodeSlideSourceLogicalRevision( + source: SourceRecord, +): NodeSlideSourceLogicalRevision { + const digest = nodeslideContentDigest(stableJson(source)); + return { + sourceId: source.id, + deckId: source.deckId, + revisionId: `source-revision:${digest}`, + digest, + retrievedAt: source.retrievedAt, + ...(source.contentDigest ? { contentDigest: source.contentDigest } : {}), + }; +} + +/** Detects a source transition without mutating either supplied snapshot. */ +export function detectNodeSlideSourceChange( + args: DetectNodeSlideSourceChangeArgs, +): NodeSlideSourceChangeDescriptor { + const { before, after, transitionHint } = args; + if (!before && !after) + throw new Error('Source change detection requires a before or after source.'); + if (before && after && before.deckId !== after.deckId) { + throw new Error('Source revisions must belong to the same deck.'); + } + + validateTransitionHint(before, after, transitionHint); + + const beforeRevision = before ? nodeSlideSourceLogicalRevision(before) : undefined; + const afterRevision = after ? nodeSlideSourceLogicalRevision(after) : undefined; + const changedFields = changedSourceFields(before, after); + const kind = sourceChangeKind(before, after, beforeRevision, afterRevision, transitionHint); + const material = + kind !== 'unchanged' && + (kind !== 'updated' || changedFields.some((field) => !BOOKKEEPING_FIELDS.has(field))); + const affectedSourceIds = material + ? uniqueSorted([before?.id, after?.id].filter((id): id is string => Boolean(id))) + : []; + const logicalSourceId = before?.id ?? after?.id ?? ''; + const reason = changeReason(kind, material, changedFields, transitionHint); + const descriptorForDigest = { + kind, + logicalSourceId, + beforeRevision: beforeRevision?.digest ?? null, + afterRevision: afterRevision?.digest ?? null, + changedFields, + material, + affectedSourceIds, + transitionHint: transitionHint ?? null, + }; + + return { + kind, + logicalSourceId, + ...(beforeRevision ? { beforeRevision } : {}), + ...(afterRevision ? { afterRevision } : {}), + changedFields, + material, + affectedSourceIds, + digest: nodeslideContentDigest(stableJson(descriptorForDigest)), + reason, + ...(transitionHint ? { transitionHint: { ...transitionHint } } : {}), + }; +} + +/** + * Joins a logical source change to current element and claim bindings. Output is + * deterministic and proposal-only; callers decide how each work item is executed. + */ +export function planNodeSlideSourceRefresh( + args: PlanNodeSlideSourceRefreshArgs, +): NodeSlideSourceRefreshPlan { + const change = detectNodeSlideSourceChange(args); + assertDeckScope(args.snapshot, args.before, args.after); + + const affectedSourceIds = new Set(change.affectedSourceIds); + const slideRank = rank(args.snapshot.deck.slideOrder); + const slideById = new Map(args.snapshot.slides.map((slide) => [slide.id, slide])); + const elementById = new Map(args.snapshot.elements.map((element) => [element.id, element])); + const elementOrderRank = new Map(); + for (const slide of args.snapshot.slides) { + slide.elementOrder.forEach((elementId, index) => elementOrderRank.set(elementId, index)); + } + + const affectedClaims = deduplicateClaims( + (args.claimSourceBindings ?? []).flatMap((binding): NodeSlideAffectedSourceClaim[] => { + const element = elementById.get(binding.elementId); + if (!element || element.slideId !== binding.slideId || !slideById.has(binding.slideId)) + return []; + const matchedSourceIds = uniqueSorted( + binding.sourceIds.filter((sourceId) => affectedSourceIds.has(sourceId)), + ); + if (matchedSourceIds.length === 0) return []; + return [ + { + operationIndex: binding.operationIndex, + operation: binding.operation, + slideId: binding.slideId, + elementId: binding.elementId, + sourceIds: uniqueSorted(binding.sourceIds), + matchedSourceIds, + claimDigest: binding.claimDigest, + reason: `Claim ${binding.claimDigest} is bound to changed source ${matchedSourceIds.join(', ')}.`, + }, + ]; + }), + ).sort((left, right) => compareImpact(left, right, slideRank, elementOrderRank)); + + const claimsByElement = groupClaimsByElement(affectedClaims); + const affectedElements = args.snapshot.elements + .flatMap((element): NodeSlideAffectedSourceElement[] => { + if (!slideById.has(element.slideId)) return []; + const directlyMatchedSourceIds = boundSourceIds(element).filter((sourceId) => + affectedSourceIds.has(sourceId), + ); + const claims = claimsByElement.get(element.id) ?? []; + const sourceIds = uniqueSorted([ + ...directlyMatchedSourceIds, + ...claims.flatMap((claim) => claim.matchedSourceIds), + ]); + if (sourceIds.length === 0) return []; + const reasons: NodeSlideAffectedSourceElement['reasons'] = []; + if (directlyMatchedSourceIds.length > 0) reasons.push('element_source_binding'); + if (claims.length > 0) reasons.push('claim_source_binding'); + const claimDigests = uniqueSorted(claims.map((claim) => claim.claimDigest)); + return [ + { + slideId: element.slideId, + elementId: element.id, + sourceIds, + claimDigests, + reasons, + reason: elementReason(sourceIds, claimDigests), + }, + ]; + }) + .sort((left, right) => compareImpact(left, right, slideRank, elementOrderRank)); + + const affectedSlides = buildAffectedSlides(affectedElements, slideRank); + const replacementSourceId = replacementTarget(change); + const operations = affectedElements.map( + (element): NodeSlideSourceRefreshOperation => ({ + kind: 'refresh_source_bound_element', + slideId: element.slideId, + elementId: element.elementId, + sourceIds: [...element.sourceIds], + claimDigests: [...element.claimDigests], + ...(replacementSourceId ? { replacementSourceId } : {}), + reason: `${element.reason} ${change.reason}`, + }), + ); + const batches = chunkOperations(operations); + const reason = planReason(change, affectedElements.length); + const planForDigest = { + changeDigest: change.digest, + affectedSlides, + affectedElements, + affectedClaims, + batches, + operationCount: operations.length, + }; + + return { + change, + affectedSlides, + affectedElements, + affectedClaims, + batches, + operationCount: operations.length, + digest: nodeslideContentDigest(stableJson(planForDigest)), + reason, + }; +} + +function validateTransitionHint( + before: SourceRecord | null, + after: SourceRecord | null, + hint: NodeSlideSourceTransitionHint | undefined, +): void { + if (before && after && before.id !== after.id && !hint) { + throw new Error('Different source IDs require an explicit replacement or supersession hint.'); + } + if (!hint) return; + if (!before || !after) { + throw new Error('A source transition hint requires both before and after sources.'); + } + if ( + hint.beforeSourceId !== before.id || + hint.afterSourceId !== after.id || + hint.beforeSourceId === hint.afterSourceId + ) { + throw new Error('Source transition hint IDs must exactly match distinct source snapshots.'); + } + if (hint.reason !== undefined && !hint.reason.trim()) { + throw new Error('Source transition hint reason must be non-empty when supplied.'); + } +} + +function assertDeckScope( + snapshot: DeckSnapshot, + before: SourceRecord | null, + after: SourceRecord | null, +): void { + for (const source of [before, after]) { + if (source && source.deckId !== snapshot.deck.id) { + throw new Error('Source refresh planning is restricted to the snapshot deck.'); + } + } +} + +function sourceChangeKind( + before: SourceRecord | null, + after: SourceRecord | null, + beforeRevision: NodeSlideSourceLogicalRevision | undefined, + afterRevision: NodeSlideSourceLogicalRevision | undefined, + hint: NodeSlideSourceTransitionHint | undefined, +): NodeSlideSourceChangeKind { + if (!before) return 'created'; + if (!after) return 'removed'; + if (hint) return hint.kind === 'replacement' ? 'replaced' : 'superseded'; + return beforeRevision?.digest === afterRevision?.digest ? 'unchanged' : 'updated'; +} + +function changedSourceFields( + before: SourceRecord | null, + after: SourceRecord | null, +): Array { + if (!before || !after) return [...SOURCE_FIELDS]; + return SOURCE_FIELDS.filter((field) => stableJson(before[field]) !== stableJson(after[field])); +} + +function changeReason( + kind: NodeSlideSourceChangeKind, + material: boolean, + changedFields: readonly (keyof SourceRecord)[], + hint: NodeSlideSourceTransitionHint | undefined, +): string { + if (kind === 'unchanged') return 'The source snapshots resolve to the same logical revision.'; + if (!material) { + return `Only source retrieval bookkeeping changed (${changedFields.join(', ')}); deck content is not stale.`; + } + if (kind === 'created') + return 'A source was created and its current bindings require refresh review.'; + if (kind === 'removed') + return 'A bound source was removed and its dependent content requires review.'; + if (kind === 'replaced' || kind === 'superseded') { + const transition = + hint?.reason?.trim() ?? `Source ${hint?.beforeSourceId} -> ${hint?.afterSourceId}`; + return `${kind === 'replaced' ? 'Replacement' : 'Supersession'}: ${transition}.`; + } + return `Source fields changed (${changedFields.join(', ')}), creating a new logical revision.`; +} + +function replacementTarget(change: NodeSlideSourceChangeDescriptor): string | undefined { + return change.kind === 'replaced' || change.kind === 'superseded' + ? change.transitionHint?.afterSourceId + : undefined; +} + +function boundSourceIds(element: SlideElement): string[] { + return uniqueSorted( + [ + ...element.sourceIds, + element.chart?.sourceId, + element.math?.sourceId, + element.image?.sourceId, + ].filter((sourceId): sourceId is string => Boolean(sourceId)), + ); +} + +function deduplicateClaims( + claims: readonly NodeSlideAffectedSourceClaim[], +): NodeSlideAffectedSourceClaim[] { + const unique = new Map(); + for (const claim of claims) { + const key = stableJson({ + operationIndex: claim.operationIndex, + operation: claim.operation, + slideId: claim.slideId, + elementId: claim.elementId, + sourceIds: claim.sourceIds, + claimDigest: claim.claimDigest, + }); + if (!unique.has(key)) unique.set(key, claim); + } + return [...unique.values()]; +} + +function groupClaimsByElement( + claims: readonly NodeSlideAffectedSourceClaim[], +): Map { + const grouped = new Map(); + for (const claim of claims) { + const existing = grouped.get(claim.elementId) ?? []; + existing.push(claim); + grouped.set(claim.elementId, existing); + } + return grouped; +} + +function buildAffectedSlides( + elements: readonly NodeSlideAffectedSourceElement[], + slideRank: ReadonlyMap, +): NodeSlideAffectedSourceSlide[] { + const grouped = new Map(); + for (const element of elements) { + const existing = grouped.get(element.slideId) ?? []; + existing.push(element); + grouped.set(element.slideId, existing); + } + return [...grouped.entries()] + .map(([slideId, slideElements]) => { + const elementIds = slideElements.map((element) => element.elementId); + const claimDigests = uniqueSorted(slideElements.flatMap((element) => element.claimDigests)); + return { + slideId, + elementIds, + claimDigests, + reason: `${elementIds.length} source-bound element${elementIds.length === 1 ? '' : 's'} require refresh review.`, + }; + }) + .sort( + (left, right) => + (slideRank.get(left.slideId) ?? Number.MAX_SAFE_INTEGER) - + (slideRank.get(right.slideId) ?? Number.MAX_SAFE_INTEGER) || + left.slideId.localeCompare(right.slideId), + ); +} + +function elementReason(sourceIds: readonly string[], claimDigests: readonly string[]): string { + const claimReason = + claimDigests.length > 0 + ? ` It carries ${claimDigests.length} affected claim${claimDigests.length === 1 ? '' : 's'}.` + : ''; + return `Element is bound to changed source ${sourceIds.join(', ')}.${claimReason}`; +} + +function planReason(change: NodeSlideSourceChangeDescriptor, operationCount: number): string { + if (!change.material) return `${change.reason} No refresh operations were planned.`; + if (operationCount === 0) { + return `${change.reason} No slide elements or claims are bound to the affected source IDs.`; + } + return `${change.reason} Planned ${operationCount} bounded refresh operation${operationCount === 1 ? '' : 's'}.`; +} + +function chunkOperations( + operations: readonly NodeSlideSourceRefreshOperation[], +): NodeSlideSourceRefreshBatch[] { + const batches: NodeSlideSourceRefreshBatch[] = []; + for (let start = 0; start < operations.length; start += NODESLIDE_SOURCE_REFRESH_BATCH_LIMIT) { + const batchOperations = operations + .slice(start, start + NODESLIDE_SOURCE_REFRESH_BATCH_LIMIT) + .map((operation) => ({ ...operation })); + batches.push({ + index: batches.length, + operations: batchOperations, + reason: `Bounded refresh batch ${batches.length + 1} contains ${batchOperations.length} operation${batchOperations.length === 1 ? '' : 's'}.`, + }); + } + return batches; +} + +function compareImpact( + left: { slideId: string; elementId: string; operationIndex?: number; claimDigest?: string }, + right: { slideId: string; elementId: string; operationIndex?: number; claimDigest?: string }, + slideRank: ReadonlyMap, + elementRank: ReadonlyMap, +): number { + return ( + (slideRank.get(left.slideId) ?? Number.MAX_SAFE_INTEGER) - + (slideRank.get(right.slideId) ?? Number.MAX_SAFE_INTEGER) || + left.slideId.localeCompare(right.slideId) || + (elementRank.get(left.elementId) ?? Number.MAX_SAFE_INTEGER) - + (elementRank.get(right.elementId) ?? Number.MAX_SAFE_INTEGER) || + left.elementId.localeCompare(right.elementId) || + (left.operationIndex ?? Number.MAX_SAFE_INTEGER) - + (right.operationIndex ?? Number.MAX_SAFE_INTEGER) || + (left.claimDigest ?? '').localeCompare(right.claimDigest ?? '') + ); +} + +function rank(ids: readonly string[]): Map { + return new Map(ids.map((id, index) => [id, index])); +} + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values)].sort((left, right) => left.localeCompare(right)); +} + +function stableJson(value: unknown): string { + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} diff --git a/convex/lib/nodeslideSourceRevision.test.ts b/convex/lib/nodeslideSourceRevision.test.ts new file mode 100644 index 0000000..21c0070 --- /dev/null +++ b/convex/lib/nodeslideSourceRevision.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; +import type { SourceRecord } from '../../shared/nodeslide'; +import { + assertNodeSlideSourceRevision, + buildNodeSlideSourceRevision, + isNodeSlideSourceRevision, +} from './nodeslideSourceRevision'; + +const CONTENT_DIGEST = `sha256:${'1'.repeat(64)}`; + +describe('NodeSlide immutable source revisions', () => { + it('builds a deterministic, content-addressed revision without mutating its source', () => { + const input = source({ url: 'https://Example.com/report#page-2', columns: ['Year', 'Value'] }); + const original = structuredClone(input); + + const first = buildNodeSlideSourceRevision({ source: input }); + const second = buildNodeSlideSourceRevision({ + source: { ...input, status: 'refreshing', lastRefreshedAt: input.retrievedAt + 10 }, + }); + + expect(first).toEqual(second); + expect(first).toMatchObject({ + schema: 'nodeslide.source-revision/v1', + sourceId: 'source-a', + deckId: 'deck-a', + url: 'https://example.com/report', + contentDigest: CONTENT_DIGEST, + revisionId: expect.stringMatching(/^source-revision:sha256:[0-9a-f]{64}$/), + revisionDigest: expect.stringMatching(/^sha256:[0-9a-f]{64}$/), + columns: ['Year', 'Value'], + }); + expect(first.revisionId).toBe(`source-revision:${first.revisionDigest}`); + expect(input).toEqual(original); + expect(Object.isFrozen(first)).toBe(true); + expect(Object.isFrozen(first.columns)).toBe(true); + expect(() => assertNodeSlideSourceRevision(first)).not.toThrow(); + expect(isNodeSlideSourceRevision(first)).toBe(true); + }); + + it('supports a digest-bound predecessor and changes identity when evidence changes', () => { + const predecessor = buildNodeSlideSourceRevision({ source: source() }); + const next = buildNodeSlideSourceRevision({ + source: source({ contentDigest: `sha256:${'2'.repeat(64)}` }), + predecessor, + }); + + expect(next.predecessor).toEqual({ + revisionId: predecessor.revisionId, + revisionDigest: predecessor.revisionDigest, + }); + expect(Object.isFrozen(next.predecessor)).toBe(true); + expect(next.revisionDigest).not.toBe(predecessor.revisionDigest); + }); + + it('requires exact retained content and canonical source metadata', () => { + expect(() => + buildNodeSlideSourceRevision({ source: source({ contentDigest: undefined }) }), + ).toThrow('content digest must be a canonical SHA-256 digest'); + expect(() => + buildNodeSlideSourceRevision({ source: source({ title: ' untrimmed ' }) }), + ).toThrow('title must be non-empty, trimmed'); + expect(() => + buildNodeSlideSourceRevision({ source: source({ url: 'file:///private/report.pdf' }) }), + ).toThrow('URL must use HTTP or HTTPS'); + expect(() => + buildNodeSlideSourceRevision({ + source: source({ url: 'https://user:secret@example.com/report' }), + }), + ).toThrow('URL cannot contain credentials'); + expect(() => + buildNodeSlideSourceRevision({ source: source({ columns: ['Value', 'Value'] }) }), + ).toThrow('duplicate columns'); + expect(() => buildNodeSlideSourceRevision({ source: source({ byteSize: -1 }) })).toThrow( + 'byteSize must be a non-negative safe integer', + ); + }); + + it('rejects predecessor identities that are not bound to their digest', () => { + const predecessor = buildNodeSlideSourceRevision({ source: source() }); + + expect(() => + buildNodeSlideSourceRevision({ + source: source({ contentDigest: `sha256:${'2'.repeat(64)}` }), + predecessor: { + revisionId: predecessor.revisionId, + revisionDigest: `sha256:${'3'.repeat(64)}`, + }, + }), + ).toThrow('predecessor revision ID is not bound to its digest'); + }); + + it('detects metadata, content, identity, and extension-field tampering', () => { + const revision = buildNodeSlideSourceRevision({ source: source() }); + const mutations: unknown[] = [ + { ...revision, title: 'Changed title' }, + { ...revision, contentDigest: `sha256:${'9'.repeat(64)}` }, + { ...revision, revisionDigest: `sha256:${'8'.repeat(64)}` }, + { ...revision, revisionId: `source-revision:sha256:${'7'.repeat(64)}` }, + { ...revision, status: 'ready' }, + ]; + + for (const tampered of mutations) { + expect(() => assertNodeSlideSourceRevision(tampered)).toThrow(); + expect(isNodeSlideSourceRevision(tampered)).toBe(false); + } + }); +}); + +function source(overrides: Partial = {}): SourceRecord { + return { + id: 'source-a', + deckId: 'deck-a', + title: 'Audited quarterly report', + url: 'https://example.com/report', + sourceType: 'document', + retrievedAt: Date.parse('2026-07-16T12:00:00.000Z'), + citation: 'Example Corp. Audited quarterly report, 2026.', + format: 'web', + contentDigest: CONTENT_DIGEST, + byteSize: 42_000, + provider: 'direct-upload', + retention: 'until_deleted', + status: 'ready', + lastRefreshedAt: Date.parse('2026-07-16T12:00:00.000Z'), + ...overrides, + }; +} diff --git a/convex/lib/nodeslideSourceRevision.ts b/convex/lib/nodeslideSourceRevision.ts new file mode 100644 index 0000000..e8b21b4 --- /dev/null +++ b/convex/lib/nodeslideSourceRevision.ts @@ -0,0 +1,323 @@ +import type { SourceRecord } from '../../shared/nodeslide'; +import { nodeslideContentDigest } from './nodeslideIds'; + +export const NODESLIDE_SOURCE_REVISION_SCHEMA = 'nodeslide.source-revision/v1' as const; + +const SHA_256_DIGEST = /^sha256:[0-9a-f]{64}$/; +const SOURCE_REVISION_ID = /^source-revision:sha256:[0-9a-f]{64}$/; +const MAX_ID_LENGTH = 256; +const MAX_TITLE_LENGTH = 1_000; +const MAX_CITATION_LENGTH = 8_000; +const MAX_METADATA_LENGTH = 512; +const MAX_COLUMNS = 2_048; + +export interface NodeSlideSourceRevisionPredecessor { + readonly revisionId: string; + readonly revisionDigest: string; +} + +/** + * Content-addressed source evidence. Unlike SourceRecord, this value contains no + * mutable ingestion status and can be safely retained by historical receipts. + */ +export interface NodeSlideImmutableSourceRevision { + readonly schema: typeof NODESLIDE_SOURCE_REVISION_SCHEMA; + readonly revisionId: string; + readonly revisionDigest: string; + readonly sourceId: string; + readonly deckId: string; + readonly title: string; + readonly url?: string; + readonly sourceType: SourceRecord['sourceType']; + readonly retrievedAt: number; + readonly citation: string; + readonly license?: string; + readonly format?: SourceRecord['format']; + /** Digest of the exact retained bytes/text, not of mutable source metadata. */ + readonly contentDigest: string; + readonly byteSize?: number; + readonly rowCount?: number; + readonly columns?: readonly string[]; + readonly provider?: string; + readonly retention?: SourceRecord['retention']; + readonly predecessor?: NodeSlideSourceRevisionPredecessor; +} + +export interface BuildNodeSlideSourceRevisionArgs { + source: SourceRecord; + /** Overrides source.contentDigest when the exact retained artifact was just captured. */ + contentDigest?: string; + predecessor?: Pick; +} + +type RevisionPayload = Omit; + +/** Builds a deterministic, deeply frozen revision of exact retained source evidence. */ +export function buildNodeSlideSourceRevision( + args: BuildNodeSlideSourceRevisionArgs, +): NodeSlideImmutableSourceRevision { + const contentDigest = args.contentDigest ?? args.source.contentDigest; + assertCanonicalDigest(contentDigest, 'Source revision content digest'); + + const payload: RevisionPayload = { + schema: NODESLIDE_SOURCE_REVISION_SCHEMA, + sourceId: cleanRequired(args.source.id, 'Source revision source ID', MAX_ID_LENGTH), + deckId: cleanRequired(args.source.deckId, 'Source revision deck ID', MAX_ID_LENGTH), + title: cleanRequired(args.source.title, 'Source revision title', MAX_TITLE_LENGTH), + ...(args.source.url ? { url: canonicalPublicUrl(args.source.url) } : {}), + sourceType: args.source.sourceType, + retrievedAt: nonNegativeInteger(args.source.retrievedAt, 'Source revision retrievedAt'), + citation: cleanRequired(args.source.citation, 'Source revision citation', MAX_CITATION_LENGTH), + ...(args.source.license + ? { + license: cleanRequired( + args.source.license, + 'Source revision license', + MAX_METADATA_LENGTH, + ), + } + : {}), + ...(args.source.format ? { format: args.source.format } : {}), + contentDigest, + ...(args.source.byteSize !== undefined + ? { byteSize: nonNegativeInteger(args.source.byteSize, 'Source revision byteSize') } + : {}), + ...(args.source.rowCount !== undefined + ? { rowCount: nonNegativeInteger(args.source.rowCount, 'Source revision rowCount') } + : {}), + ...(args.source.columns ? { columns: canonicalColumns(args.source.columns) } : {}), + ...(args.source.provider + ? { + provider: cleanRequired( + args.source.provider, + 'Source revision provider', + MAX_METADATA_LENGTH, + ), + } + : {}), + ...(args.source.retention ? { retention: args.source.retention } : {}), + ...(args.predecessor + ? { + predecessor: canonicalPredecessor(args.predecessor), + } + : {}), + }; + assertSourceType(payload.sourceType); + assertFormat(payload.format); + assertRetention(payload.retention); + + const revisionDigest = sourceRevisionDigest(payload); + const revision = { + ...payload, + revisionId: `source-revision:${revisionDigest}`, + revisionDigest, + } satisfies NodeSlideImmutableSourceRevision; + assertNodeSlideSourceRevision(revision); + return deepFreeze(revision); +} + +/** Rejects malformed, mutable-field-bearing, or digest-tampered source revisions. */ +export function assertNodeSlideSourceRevision( + value: unknown, +): asserts value is NodeSlideImmutableSourceRevision { + if (!isRecord(value)) invalid('must be an object'); + const allowed = new Set([ + 'schema', + 'revisionId', + 'revisionDigest', + 'sourceId', + 'deckId', + 'title', + 'url', + 'sourceType', + 'retrievedAt', + 'citation', + 'license', + 'format', + 'contentDigest', + 'byteSize', + 'rowCount', + 'columns', + 'provider', + 'retention', + 'predecessor', + ]); + if (Object.keys(value).some((key) => !allowed.has(key))) { + invalid('contains fields outside the immutable revision contract'); + } + if (value['schema'] !== NODESLIDE_SOURCE_REVISION_SCHEMA) invalid('schema is invalid'); + if (typeof value['revisionId'] !== 'string' || !SOURCE_REVISION_ID.test(value['revisionId'])) { + invalid('revision ID is invalid'); + } + assertCanonicalDigest(value['revisionDigest'], 'Source revision digest'); + assertCanonicalDigest(value['contentDigest'], 'Source revision content digest'); + cleanRequired(value['sourceId'], 'Source revision source ID', MAX_ID_LENGTH); + cleanRequired(value['deckId'], 'Source revision deck ID', MAX_ID_LENGTH); + cleanRequired(value['title'], 'Source revision title', MAX_TITLE_LENGTH); + cleanRequired(value['citation'], 'Source revision citation', MAX_CITATION_LENGTH); + if (value['url'] !== undefined && canonicalPublicUrl(value['url']) !== value['url']) { + invalid('URL is not canonical'); + } + assertSourceType(value['sourceType']); + nonNegativeInteger(value['retrievedAt'], 'Source revision retrievedAt'); + if (value['license'] !== undefined) { + cleanRequired(value['license'], 'Source revision license', MAX_METADATA_LENGTH); + } + assertFormat(value['format']); + if (value['byteSize'] !== undefined) { + nonNegativeInteger(value['byteSize'], 'Source revision byteSize'); + } + if (value['rowCount'] !== undefined) { + nonNegativeInteger(value['rowCount'], 'Source revision rowCount'); + } + if (value['columns'] !== undefined) { + if (!Array.isArray(value['columns'])) invalid('columns must be an array'); + const canonical = canonicalColumns(value['columns']); + if (stableJson(canonical) !== stableJson(value['columns'])) { + invalid('columns are not canonical'); + } + } + if (value['provider'] !== undefined) { + cleanRequired(value['provider'], 'Source revision provider', MAX_METADATA_LENGTH); + } + assertRetention(value['retention']); + if (value['predecessor'] !== undefined) { + if (!isRecord(value['predecessor'])) invalid('predecessor is invalid'); + const predecessor = canonicalPredecessor(value['predecessor']); + if (stableJson(predecessor) !== stableJson(value['predecessor'])) { + invalid('predecessor is not canonical'); + } + if (predecessor.revisionId === value['revisionId']) invalid('cannot reference itself'); + } + + const { revisionId: _revisionId, revisionDigest, ...payload } = value; + const expectedDigest = sourceRevisionDigest(payload as RevisionPayload); + if (revisionDigest !== expectedDigest) invalid('digest binding is invalid'); + if (value['revisionId'] !== `source-revision:${expectedDigest}`) { + invalid('revision ID is not bound to its digest'); + } +} + +export function isNodeSlideSourceRevision( + value: unknown, +): value is NodeSlideImmutableSourceRevision { + try { + assertNodeSlideSourceRevision(value); + return true; + } catch { + return false; + } +} + +function sourceRevisionDigest(payload: RevisionPayload): string { + return nodeslideContentDigest(stableJson(payload)); +} + +function canonicalPredecessor(value: unknown): NodeSlideSourceRevisionPredecessor { + if (!isRecord(value)) invalid('predecessor is invalid'); + const revisionId = value['revisionId']; + const revisionDigest = value['revisionDigest']; + if (typeof revisionId !== 'string' || !SOURCE_REVISION_ID.test(revisionId)) { + invalid('predecessor revision ID is invalid'); + } + assertCanonicalDigest(revisionDigest, 'Source revision predecessor digest'); + if (revisionId !== `source-revision:${revisionDigest}`) { + invalid('predecessor revision ID is not bound to its digest'); + } + return { revisionId, revisionDigest }; +} + +function canonicalColumns(values: readonly unknown[]): readonly string[] { + if (values.length > MAX_COLUMNS) invalid('contains too many columns'); + const columns = values.map((value) => + cleanRequired(value, 'Source revision column', MAX_METADATA_LENGTH), + ); + if (new Set(columns).size !== columns.length) invalid('contains duplicate columns'); + return columns; +} + +function canonicalPublicUrl(value: unknown): string { + const raw = cleanRequired(value, 'Source revision URL', 8_000); + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + invalid('URL is invalid'); + } + if (parsed.username || parsed.password) invalid('URL cannot contain credentials'); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + invalid('URL must use HTTP or HTTPS'); + } + parsed.hash = ''; + return parsed.toString(); +} + +function assertSourceType(value: unknown): asserts value is SourceRecord['sourceType'] { + if (!['internal', 'url', 'document', 'spreadsheet', 'note'].includes(String(value))) { + invalid('source type is invalid'); + } +} + +function assertFormat(value: unknown): asserts value is SourceRecord['format'] | undefined { + if (value !== undefined && !['csv', 'json', 'txt', 'web'].includes(String(value))) { + invalid('format is invalid'); + } +} + +function assertRetention(value: unknown): asserts value is SourceRecord['retention'] | undefined { + if (value !== undefined && !['until_deleted', 'public_snapshot'].includes(String(value))) { + invalid('retention is invalid'); + } +} + +function assertCanonicalDigest(value: unknown, label: string): asserts value is string { + if (typeof value !== 'string' || !SHA_256_DIGEST.test(value)) { + throw new Error(`${label} must be a canonical SHA-256 digest.`); + } +} + +function cleanRequired(value: unknown, label: string, maxLength: number): string { + if (typeof value !== 'string') throw new Error(`${label} must be a string.`); + const clean = value.trim(); + if (!clean || clean.length > maxLength || clean !== value) { + throw new Error(`${label} must be non-empty, trimmed, and at most ${maxLength} characters.`); + } + return clean; +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || Number(value) < 0) { + throw new Error(`${label} must be a non-negative safe integer.`); + } + return Number(value); +} + +function stableJson(value: unknown): string { + if (value === undefined) return 'undefined'; + if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`; + if (value && typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .filter((key) => record[key] !== undefined) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + for (const child of Object.values(value as Record)) deepFreeze(child); + Object.freeze(value); + } + return value; +} + +function invalid(reason: string): never { + throw new Error(`NodeSlide source revision ${reason}.`); +} diff --git a/convex/lib/nodeslideUploadPolicy.test.ts b/convex/lib/nodeslideUploadPolicy.test.ts new file mode 100644 index 0000000..cae1463 --- /dev/null +++ b/convex/lib/nodeslideUploadPolicy.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { + NODESLIDE_UPLOAD_POLICIES, + assertNodeSlideStoredFileMatches, + assertNodeSlideUploadModelAccessible, + isNodeSlideUploadModelAccessible, + nodeSlideUploadRequestFingerprint, + validateNodeSlideUploadRequest, +} from './nodeslideUploadPolicy'; + +const DIGEST = 'sha256-digest-value'; + +function request(overrides: Partial[0]> = {}) { + return { + fileName: 'brief.pdf', + contentType: 'application/pdf', + byteSize: 1_024, + contentDigest: DIGEST, + clientSessionId: 'session_1', + idempotencyKey: 'upload_1', + ...overrides, + }; +} + +describe('NodeSlide upload policy', () => { + it('covers the requested structured, office, image, and presentation formats', () => { + expect(NODESLIDE_UPLOAD_POLICIES.map(({ format }) => format)).toEqual([ + 'csv', + 'json', + 'txt', + 'md', + 'pdf', + 'docx', + 'xlsx', + 'png', + 'jpeg', + 'webp', + 'gif', + 'pptx', + ]); + }); + + it('normalizes MIME parameters and binds the matching extension', () => { + expect( + validateNodeSlideUploadRequest(request({ contentType: 'Application/PDF; charset=binary' })), + ).toMatchObject({ format: 'pdf', contentType: 'application/pdf' }); + }); + + it.each([ + { fileName: '../brief.pdf' }, + { fileName: 'brief.exe', contentType: 'application/octet-stream' }, + { fileName: 'brief.pdf', contentType: 'image/png' }, + { fileName: 'brief.svg', contentType: 'image/svg+xml' }, + { fileName: 'brief.pdf', byteSize: 25 * 1024 * 1024 + 1 }, + { fileName: 'brief.pdf', byteSize: 0 }, + ])('rejects an unsafe or unsupported upload: %o', (override) => { + expect(() => validateNodeSlideUploadRequest(request(override))).toThrow(/NodeSlide|invalid/i); + }); + + it('verifies MIME, size, and digest against storage metadata', () => { + const normalized = validateNodeSlideUploadRequest(request()); + expect(() => + assertNodeSlideStoredFileMatches(normalized, { + contentType: 'application/pdf', + size: 1_024, + sha256: DIGEST, + }), + ).not.toThrow(); + expect(() => + assertNodeSlideStoredFileMatches(normalized, { + contentType: 'application/pdf', + size: 1_025, + sha256: DIGEST, + }), + ).toThrow(/size/i); + expect(() => + assertNodeSlideStoredFileMatches(normalized, { + contentType: 'application/pdf', + size: 1_024, + sha256: 'different', + }), + ).toThrow(/digest/i); + }); + + it('produces a stable fingerprint and changes it when content changes', () => { + const first = validateNodeSlideUploadRequest(request()); + const replay = validateNodeSlideUploadRequest(request()); + const changed = validateNodeSlideUploadRequest(request({ contentDigest: 'other-digest' })); + expect(nodeSlideUploadRequestFingerprint('deck_1', first)).toBe( + nodeSlideUploadRequestFingerprint('deck_1', replay), + ); + expect(nodeSlideUploadRequestFingerprint('deck_1', first)).not.toBe( + nodeSlideUploadRequestFingerprint('deck_1', changed), + ); + }); + + it('allows model access only after registration, approval, and quarantine release', () => { + const approved = { + lifecycleStatus: 'registered' as const, + securityStatus: 'approved' as const, + quarantineStatus: 'released' as const, + }; + expect(isNodeSlideUploadModelAccessible(approved)).toBe(true); + expect(() => assertNodeSlideUploadModelAccessible(approved)).not.toThrow(); + + for (const inaccessible of [ + { ...approved, lifecycleStatus: 'awaiting_upload' as const }, + { ...approved, securityStatus: 'pending' as const }, + { ...approved, securityStatus: 'rejected' as const }, + { ...approved, quarantineStatus: 'quarantined' as const }, + ]) { + expect(isNodeSlideUploadModelAccessible(inaccessible)).toBe(false); + expect(() => assertNodeSlideUploadModelAccessible(inaccessible)).toThrow(/not approved/i); + } + }); +}); diff --git a/convex/lib/nodeslideUploadPolicy.ts b/convex/lib/nodeslideUploadPolicy.ts new file mode 100644 index 0000000..3e66689 --- /dev/null +++ b/convex/lib/nodeslideUploadPolicy.ts @@ -0,0 +1,251 @@ +import type { + NodeSlideStoredAttachmentFormat, + NodeSlideUploadLifecycleStatus, + NodeSlideUploadQuarantineStatus, + NodeSlideUploadSecurityStatus, +} from '../../shared/nodeslideAttachments'; +import { nodeslideContentDigest } from './nodeslideIds'; + +export const NODESLIDE_UPLOAD_LIMITS = { + fileNameCharacters: 180, + clientSessionIdCharacters: 256, + idempotencyKeyCharacters: 160, + digestCharacters: 128, + listItems: 100, +} as const; + +export type NodeSlideUploadPolicy = { + format: NodeSlideStoredAttachmentFormat; + extensions: readonly string[]; + contentTypes: readonly string[]; + maxBytes: number; +}; + +const MiB = 1024 * 1024; + +export const NODESLIDE_UPLOAD_POLICIES: readonly NodeSlideUploadPolicy[] = [ + { format: 'csv', extensions: ['csv'], contentTypes: ['text/csv'], maxBytes: 10 * MiB }, + { + format: 'json', + extensions: ['json'], + contentTypes: ['application/json'], + maxBytes: 10 * MiB, + }, + { format: 'txt', extensions: ['txt'], contentTypes: ['text/plain'], maxBytes: 5 * MiB }, + { + format: 'md', + extensions: ['md', 'markdown'], + contentTypes: ['text/markdown'], + maxBytes: 5 * MiB, + }, + { + format: 'pdf', + extensions: ['pdf'], + contentTypes: ['application/pdf'], + maxBytes: 25 * MiB, + }, + { + format: 'docx', + extensions: ['docx'], + contentTypes: ['application/vnd.openxmlformats-officedocument.wordprocessingml.document'], + maxBytes: 25 * MiB, + }, + { + format: 'xlsx', + extensions: ['xlsx'], + contentTypes: ['application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'], + maxBytes: 25 * MiB, + }, + { format: 'png', extensions: ['png'], contentTypes: ['image/png'], maxBytes: 15 * MiB }, + { + format: 'jpeg', + extensions: ['jpg', 'jpeg'], + contentTypes: ['image/jpeg'], + maxBytes: 15 * MiB, + }, + { + format: 'webp', + extensions: ['webp'], + contentTypes: ['image/webp'], + maxBytes: 15 * MiB, + }, + { format: 'gif', extensions: ['gif'], contentTypes: ['image/gif'], maxBytes: 15 * MiB }, + { + format: 'pptx', + extensions: ['pptx'], + contentTypes: ['application/vnd.openxmlformats-officedocument.presentationml.presentation'], + maxBytes: 50 * MiB, + }, +] as const; + +export type NodeSlideUploadRequest = { + fileName: string; + contentType: string; + byteSize: number; + contentDigest: string; + clientSessionId: string; + idempotencyKey: string; +}; + +export type NormalizedNodeSlideUploadRequest = NodeSlideUploadRequest & { + format: NodeSlideStoredAttachmentFormat; +}; + +export type NodeSlideStoredFileMetadata = { + sha256: string; + size: number; + contentType?: string; +}; + +export function validateNodeSlideUploadRequest( + request: NodeSlideUploadRequest, +): NormalizedNodeSlideUploadRequest { + const fileName = requiredFileName(request.fileName); + const contentType = normalizeContentType(request.contentType); + const byteSize = requiredPositiveInteger(request.byteSize, 'Upload byte size'); + const contentDigest = requiredOpaqueValue( + request.contentDigest, + 'Upload content digest', + NODESLIDE_UPLOAD_LIMITS.digestCharacters, + ); + const clientSessionId = requiredOpaqueValue( + request.clientSessionId, + 'Client session ID', + NODESLIDE_UPLOAD_LIMITS.clientSessionIdCharacters, + ); + const idempotencyKey = requiredOpaqueValue( + request.idempotencyKey, + 'Upload idempotency key', + NODESLIDE_UPLOAD_LIMITS.idempotencyKeyCharacters, + ); + const extension = fileName.slice(fileName.lastIndexOf('.') + 1).toLowerCase(); + const policy = NODESLIDE_UPLOAD_POLICIES.find( + (candidate) => + candidate.extensions.includes(extension) && candidate.contentTypes.includes(contentType), + ); + if (!policy) { + throw new Error('NodeSlide upload type is not allowed or does not match its file extension.'); + } + if (byteSize > policy.maxBytes) { + throw new Error(`NodeSlide ${policy.format.toUpperCase()} upload exceeds its size limit.`); + } + + return { + fileName, + contentType, + byteSize, + contentDigest, + clientSessionId, + idempotencyKey, + format: policy.format, + }; +} + +export function assertNodeSlideStoredFileMatches( + expected: Pick, + actual: NodeSlideStoredFileMetadata | null, +): asserts actual is NodeSlideStoredFileMetadata { + if (!actual) throw new Error('NodeSlide uploaded file was not found in storage.'); + if (normalizeContentType(actual.contentType ?? '') !== expected.contentType) { + throw new Error('NodeSlide uploaded file MIME type does not match the prepared upload.'); + } + if (actual.size !== expected.byteSize) { + throw new Error('NodeSlide uploaded file size does not match the prepared upload.'); + } + if (actual.sha256 !== expected.contentDigest) { + throw new Error('NodeSlide uploaded file digest does not match the prepared upload.'); + } +} + +export function nodeSlideUploadRequestFingerprint( + deckId: string, + request: NormalizedNodeSlideUploadRequest, +): string { + return nodeslideContentDigest( + [ + 'nodeslide-upload/v1', + deckId, + request.clientSessionId, + request.idempotencyKey, + request.fileName, + request.format, + request.contentType, + String(request.byteSize), + request.contentDigest, + ].join('\u001f'), + ); +} + +export function isNodeSlideUploadModelAccessible(upload: { + lifecycleStatus: NodeSlideUploadLifecycleStatus; + securityStatus: NodeSlideUploadSecurityStatus; + quarantineStatus: NodeSlideUploadQuarantineStatus; +}): boolean { + return ( + upload.lifecycleStatus === 'registered' && + upload.securityStatus === 'approved' && + upload.quarantineStatus === 'released' + ); +} + +export function assertNodeSlideUploadModelAccessible(upload: { + lifecycleStatus: NodeSlideUploadLifecycleStatus; + securityStatus: NodeSlideUploadSecurityStatus; + quarantineStatus: NodeSlideUploadQuarantineStatus; +}): void { + if (!isNodeSlideUploadModelAccessible(upload)) { + throw new Error('NodeSlide upload is not approved for model access.'); + } +} + +function requiredFileName(value: string): string { + const normalized = value.trim(); + if ( + !normalized || + normalized.length > NODESLIDE_UPLOAD_LIMITS.fileNameCharacters || + hasUnsafeFileNameCharacters(normalized) || + normalized === '.' || + normalized === '..' || + !normalized.includes('.') + ) { + throw new Error('NodeSlide upload file name is invalid.'); + } + return normalized; +} + +function hasUnsafeFileNameCharacters(value: string): boolean { + for (const character of value) { + const code = character.charCodeAt(0); + if (character === '/' || character === '\\' || code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +function normalizeContentType(value: string): string { + const normalized = value.split(';', 1)[0]?.trim().toLowerCase() ?? ''; + if ( + !normalized || + normalized.length > 160 || + !/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/u.test(normalized) + ) { + throw new Error('NodeSlide upload MIME type is invalid.'); + } + return normalized; +} + +function requiredPositiveInteger(value: number, label: string): number { + if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${label} is invalid.`); + return value; +} + +function requiredOpaqueValue(value: string, label: string, maxCharacters: number): string { + const normalized = value.trim(); + if ( + !normalized || + normalized.length > maxCharacters || + !/^[A-Za-z0-9._~+/:=@-]+$/u.test(normalized) + ) { + throw new Error(`${label} is invalid.`); + } + return normalized; +} diff --git a/convex/lib/nodeslideValidation.ts b/convex/lib/nodeslideValidation.ts index 9f32389..c9b4193 100644 --- a/convex/lib/nodeslideValidation.ts +++ b/convex/lib/nodeslideValidation.ts @@ -633,26 +633,21 @@ function validateCollisions( slideId: string, addIssue: (issue: Omit, discriminator?: string) => void, ) { - const contentElements = elements.filter( - (element) => - element.kind !== 'shape' && - element.kind !== 'connector' && - element.role !== 'footer' && - element.role !== 'page_number', - ); + const contentElements = elements.filter(isCollisionCandidate); for (let leftIndex = 0; leftIndex < contentElements.length; leftIndex += 1) { const left = contentElements[leftIndex]; if (!left) continue; for (let rightIndex = leftIndex + 1; rightIndex < contentElements.length; rightIndex += 1) { const right = contentElements[rightIndex]; if (!right) continue; + if (shouldIgnoreContainedShape(left, right)) continue; const overlap = overlapRatio(left.bbox, right.bbox); - if (overlap >= 0.35) { + if (overlap >= 0.2) { addIssue( { - severity: 'warning', + severity: 'error', code: 'collision', - message: `Elements ${left.id} and ${right.id} overlap by ${Math.round(overlap * 100)}% of the smaller region.`, + message: `Important elements "${left.id}" and "${right.id}" overlap by ${Math.round(overlap * 100)}% of the smaller element.`, slideId, elementId: right.id, }, @@ -663,6 +658,28 @@ function validateCollisions( } } +function isCollisionCandidate(element: SlideElement): boolean { + if (element.kind === 'connector') return false; + if (element.role === 'footer' || element.role === 'page_number') return false; + if (/(?:background|decorative|decoration|watermark)/iu.test(element.role ?? '')) return false; + return element.kind !== 'shape' || Boolean(element.content?.trim()); +} + +function shouldIgnoreContainedShape(first: SlideElement, second: SlideElement): boolean { + if (first.kind === 'shape' && boxContains(first.bbox, second.bbox)) return true; + if (second.kind === 'shape' && boxContains(second.bbox, first.bbox)) return true; + return false; +} + +function boxContains(outer: BoundingBox, inner: BoundingBox): boolean { + return ( + inner.x >= outer.x && + inner.y >= outer.y && + inner.x + inner.width <= outer.x + outer.width && + inner.y + inner.height <= outer.y + outer.height + ); +} + function overlapRatio(left: BoundingBox, right: BoundingBox): number { const width = Math.max( 0, diff --git a/convex/nodeslide.ts b/convex/nodeslide.ts index e1d49e6..60e3965 100644 --- a/convex/nodeslide.ts +++ b/convex/nodeslide.ts @@ -13,9 +13,11 @@ import { NODESLIDE_PATCH_OPERATION_LIMIT, NODESLIDE_REFERENCE_USE_POLICIES, type NodeSlideAgentToolActivity, + type NodeSlideEvidenceBox, type PatchOperation, type PatchScope, type PatchSource, + type SourceRecord, type ValidationResult, clampNormalized, } from '../shared/nodeslide'; @@ -46,6 +48,7 @@ import { nodeSlideCandidateValidationId, validationFromCandidateReceipt, } from './lib/nodeslideCandidate'; +import { buildNodeSlideClaimEvidenceReceipt } from './lib/nodeslideClaimEvidenceReceipt'; import { nodeSlideCreationAuthorizationLine, nodeSlideCreationRunStartedAt, @@ -119,7 +122,11 @@ import { requireDeckSignatureProfile, requireSignatureProfile, } from './lib/nodeslideSignatureProfiles'; -import { buildNodeSlideSourceLineage } from './lib/nodeslideSourceLineage'; +import { + buildNodeSlideSourceLineage, + nodeSlideOperationSourceIds, +} from './lib/nodeslideSourceLineage'; +import { buildNodeSlideSourceRevision } from './lib/nodeslideSourceRevision'; import { isNormalizedBoundingBox, validateNodeSlideSnapshot } from './lib/nodeslideValidation'; import { nodeslideBriefAttachmentValidator, @@ -145,6 +152,336 @@ const PRESENCE_TTL_MS = 45_000; const MAX_PATCH_OPERATIONS = NODESLIDE_PATCH_OPERATION_LIMIT; const MAX_PRESENCE_ELEMENTS = 64; const MAX_LISTED_DECKS = 32; +const NODESLIDE_EVIDENCE_CAPTURE_LIMIT_PER_RUN = 20; +const NODESLIDE_EVIDENCE_STEP_LIMIT_PER_CAPTURE = 20; +const NODESLIDE_EVIDENCE_CAPTURE_TTL_MS = 30 * 24 * 60 * 60 * 1_000; + +const nodeslideEvidenceBoxValidator = v.object({ + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + page: v.optional(v.number()), + pageCount: v.optional(v.number()), +}); + +const nodeslideEvidenceViewportValidator = v.object({ + width: v.number(), + height: v.number(), +}); + +function nodeSlideEvidenceOwnerDigest(ownerAccessKey: string): string { + return `actor_${nodeslideContentDigest(ownerAccessKey)}`; +} + +function sourceRecordForRevision( + source: Pick< + Doc<'nodeslide_sources'>, + | 'id' + | 'deckId' + | 'title' + | 'url' + | 'sourceType' + | 'retrievedAt' + | 'citation' + | 'license' + | 'format' + | 'contentDigest' + | 'byteSize' + | 'rowCount' + | 'columns' + | 'provider' + | 'retention' + | 'status' + | 'lastRefreshedAt' + >, + contentDigest: string, +): SourceRecord { + return { + id: source.id, + deckId: source.deckId, + title: source.title, + ...(source.url ? { url: source.url } : {}), + sourceType: source.sourceType, + retrievedAt: source.retrievedAt, + citation: source.citation, + ...(source.license ? { license: source.license } : {}), + ...(source.format ? { format: source.format } : {}), + contentDigest, + ...(source.byteSize !== undefined ? { byteSize: source.byteSize } : {}), + ...(source.rowCount !== undefined ? { rowCount: source.rowCount } : {}), + ...(source.columns ? { columns: source.columns } : {}), + ...(source.provider ? { provider: source.provider } : {}), + ...(source.retention ? { retention: source.retention } : {}), + ...(source.status ? { status: source.status } : {}), + ...(source.lastRefreshedAt !== undefined ? { lastRefreshedAt: source.lastRefreshedAt } : {}), + }; +} + +async function ensureNodeSlideSourceRevision( + ctx: Pick, + args: { + source: Doc<'nodeslide_sources'> | Omit, '_id' | '_creationTime'>; + ownerAccessKey: string; + contentDigest?: string; + createdAt?: number; + }, +): Promise<{ + id: string; + revisionDigest: string; + ownerDigest: string; + deckId: string; + sourceId: string; + contentDigest: string; + title: string; +}> { + const contentDigest = + args.contentDigest ?? args.source.contentDigest ?? nodeslideContentDigest(args.source.citation); + const ownerDigest = nodeSlideEvidenceOwnerDigest(args.ownerAccessKey); + const matches = await ctx.db + .query('nodeslide_source_revisions') + .withIndex('by_source_content_digest', (query) => + query.eq('sourceId', args.source.id).eq('contentDigest', contentDigest), + ) + .take(2); + if (matches.length > 1) { + throw new Error('Immutable source revision identity is ambiguous.'); + } + const existing = matches[0]; + if (existing) { + if (existing.deckId !== args.source.deckId || existing.ownerDigest !== ownerDigest) { + throw new Error('Immutable source revision crossed its owner or deck boundary.'); + } + return existing; + } + + const predecessor = await ctx.db + .query('nodeslide_source_revisions') + .withIndex('by_source_created', (query) => query.eq('sourceId', args.source.id)) + .order('desc') + .first(); + if (predecessor && predecessor.deckId !== args.source.deckId) { + throw new Error('Immutable source revision predecessor crossed its deck boundary.'); + } + const revision = buildNodeSlideSourceRevision({ + source: sourceRecordForRevision(args.source, contentDigest), + ...(predecessor + ? { + predecessor: { + revisionId: predecessor.id, + revisionDigest: predecessor.revisionDigest, + }, + } + : {}), + }); + await ctx.db.insert('nodeslide_source_revisions', { + id: revision.revisionId, + schema: revision.schema, + revisionDigest: revision.revisionDigest, + ownerDigest, + deckId: revision.deckId, + sourceId: revision.sourceId, + title: revision.title, + ...(revision.url ? { url: revision.url } : {}), + sourceType: revision.sourceType, + retrievedAt: revision.retrievedAt, + citation: revision.citation, + ...(revision.license ? { license: revision.license } : {}), + ...(revision.format ? { format: revision.format } : {}), + contentDigest: revision.contentDigest, + ...(revision.byteSize !== undefined ? { byteSize: revision.byteSize } : {}), + ...(revision.rowCount !== undefined ? { rowCount: revision.rowCount } : {}), + ...(revision.columns ? { columns: [...revision.columns] } : {}), + ...(revision.provider ? { provider: revision.provider } : {}), + ...(revision.retention ? { retention: revision.retention } : {}), + ...(revision.predecessor + ? { + predecessorRevisionId: revision.predecessor.revisionId, + predecessorRevisionDigest: revision.predecessor.revisionDigest, + } + : {}), + createdAt: args.createdAt ?? Date.now(), + }); + return { + id: revision.revisionId, + revisionDigest: revision.revisionDigest, + ownerDigest, + deckId: revision.deckId, + sourceId: revision.sourceId, + contentDigest: revision.contentDigest, + title: revision.title, + }; +} + +async function persistNodeSlideClaimEvidenceReceipts( + ctx: Pick, + args: { + deckId: string; + ownerAccessKey: string; + patchId: string; + traceId?: string; + runId?: string; + operations: readonly PatchOperation[]; + createdAt: number; + }, +): Promise { + if (!args.runId) return 0; + const runId = args.runId; + const sourceIds = nodeSlideOperationSourceIds(args.operations); + if (sourceIds.length === 0) return 0; + const lineage = buildNodeSlideSourceLineage({ + operations: args.operations, + authorizedSourceIds: sourceIds, + policy: 'not_applicable', + }); + if (lineage.claimSourceBindings.length === 0) return 0; + + const captures = await ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_run_created', (query) => query.eq('runId', runId)) + .order('desc') + .take(NODESLIDE_EVIDENCE_CAPTURE_LIMIT_PER_RUN + 1); + if (captures.length > NODESLIDE_EVIDENCE_CAPTURE_LIMIT_PER_RUN) { + throw new Error('Claim evidence receipt capture lookup exceeded its bounded run limit.'); + } + const latestCaptureBySource = new Map>(); + for (const capture of captures) { + if ( + capture.deckId === args.deckId && + capture.status === 'ready' && + capture.sourceRevisionId && + capture.sourceRevisionDigest && + capture.captureDigest && + !latestCaptureBySource.has(capture.sourceId) + ) { + latestCaptureBySource.set(capture.sourceId, capture); + } + } + + const ownerDigest = nodeSlideEvidenceOwnerDigest(args.ownerAccessKey); + let inserted = 0; + for (const binding of lineage.claimSourceBindings) { + for (const sourceId of binding.sourceIds) { + const capture = latestCaptureBySource.get(sourceId); + if (!capture?.sourceRevisionId || !capture.sourceRevisionDigest || !capture.captureDigest) { + continue; + } + const sourceRevisionId = capture.sourceRevisionId; + const revision = await ctx.db + .query('nodeslide_source_revisions') + .withIndex('by_stable_id', (query) => query.eq('id', sourceRevisionId)) + .unique(); + if ( + !revision || + revision.deckId !== args.deckId || + revision.sourceId !== sourceId || + revision.ownerDigest !== ownerDigest || + revision.revisionDigest !== capture.sourceRevisionDigest + ) { + throw new Error('Claim evidence source revision binding is invalid.'); + } + const steps = await ctx.db + .query('nodeslide_evidence_steps') + .withIndex('by_capture_sequence', (query) => query.eq('captureId', capture.id)) + .order('desc') + .take(NODESLIDE_EVIDENCE_STEP_LIMIT_PER_CAPTURE + 1); + if (steps.length > NODESLIDE_EVIDENCE_STEP_LIMIT_PER_CAPTURE) { + throw new Error('Claim evidence step lookup exceeded its bounded capture limit.'); + } + const step = steps.find((candidate) => { + if ( + !candidate.attachmentKind || + !candidate.box || + candidate.regionScope !== 'claim' || + !candidate.attachmentDigest || + !candidate.evidenceStepDigest + ) { + return false; + } + if (candidate.attachmentKind === 'screenshot') { + return candidate.box.page === undefined && candidate.box.pageCount === undefined; + } + return ( + Number.isInteger(candidate.box.page) && + Number.isInteger(candidate.box.pageCount) && + Number(candidate.box.page) > 0 && + Number(candidate.box.pageCount) >= Number(candidate.box.page) + ); + }); + if ( + !step?.attachmentKind || + !step.box || + !step.attachmentDigest || + !step.evidenceStepDigest + ) { + continue; + } + const receipt = buildNodeSlideClaimEvidenceReceipt({ + deckId: args.deckId, + slideId: binding.slideId, + elementId: binding.elementId, + claimDigest: binding.claimDigest, + sourceRevisionId: revision.id, + sourceRevisionDigest: revision.revisionDigest, + captureId: capture.id, + captureDigest: capture.captureDigest, + evidenceStepId: step.id, + evidenceStepDigest: step.evidenceStepDigest, + attachmentKind: step.attachmentKind, + attachmentDigest: step.attachmentDigest, + region: { + x: step.box.x, + y: step.box.y, + w: step.box.w, + h: step.box.h, + ...(step.attachmentKind === 'pdf' + ? { page: step.box.page, pageCount: step.box.pageCount } + : {}), + }, + }); + const id = nodeslideStableId('claim_evidence_receipt', args.patchId, receipt.receiptId); + const existing = await ctx.db + .query('nodeslide_claim_evidence_receipts') + .withIndex('by_stable_id', (query) => query.eq('id', id)) + .unique(); + if (existing) { + if ( + existing.deckId !== args.deckId || + existing.patchId !== args.patchId || + existing.receiptDigest !== receipt.receiptDigest + ) { + throw new Error('Claim evidence receipt idempotency binding is invalid.'); + } + continue; + } + await ctx.db.insert('nodeslide_claim_evidence_receipts', { + id, + receiptId: receipt.receiptId, + schema: receipt.schema, + receiptDigest: receipt.receiptDigest, + ownerDigest, + deckId: receipt.deckId, + patchId: args.patchId, + ...(args.traceId ? { traceId: args.traceId } : {}), + slideId: receipt.slideId, + elementId: receipt.elementId, + claimDigest: receipt.claimDigest, + sourceRevisionId: receipt.sourceRevisionId, + sourceRevisionDigest: receipt.sourceRevisionDigest, + captureId: receipt.captureId, + captureDigest: receipt.captureDigest, + evidenceStepId: receipt.evidenceStepId, + evidenceStepDigest: receipt.evidenceStepDigest, + attachmentKind: receipt.attachmentKind, + attachmentDigest: receipt.attachmentDigest, + region: receipt.region, + createdAt: args.createdAt, + }); + inserted += 1; + } + } + return inserted; +} // biome-ignore lint/suspicious/noExplicitAny: generated mutation cycle for atomic review finalization const nodeslideJobsInternal: any = (internal as any).nodeslideJobs; const patchCoreArgs = { @@ -380,6 +717,95 @@ export const attachDataSource = mutation({ if (sourceCount >= 64) throw new Error('This deck has reached its source attachment limit.'); await ctx.db.insert('nodeslide_sources', source); } + await ensureNodeSlideSourceRevision(ctx, { + source, + ownerAccessKey: args.ownerAccessKey, + contentDigest: source.contentDigest, + createdAt: source.lastRefreshedAt, + }); + return { id, kind: 'source' as const, label: `Source: ${title}` }; + }, +}); + +/** Server-only sink for approved storage-backed text uploads. */ +export const attachStoredDataSourceInternal = internalMutation({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + title: v.string(), + format: v.union(v.literal('csv'), v.literal('json'), v.literal('txt')), + preview: v.string(), + previewTruncated: v.boolean(), + contentDigest: v.string(), + byteSize: v.number(), + rowCount: v.optional(v.number()), + columns: v.optional(v.array(v.string())), + }, + handler: async (ctx, args) => { + await requireOwnerAccess(ctx, args.deckId, args.ownerAccessKey); + const title = requiredText(args.title, 'data file name', 180); + const preview = args.preview + .replace(/^\uFEFF/u, '') + .replace(/\r\n?/g, '\n') + .trim(); + if (!preview || preview.length > 7_200 || preview.includes('\u0000')) { + throw new Error('Stored data preview is invalid.'); + } + if (!Number.isSafeInteger(args.byteSize) || args.byteSize <= 0) { + throw new Error('Stored data byte size is invalid.'); + } + if ( + args.rowCount !== undefined && + (!Number.isSafeInteger(args.rowCount) || args.rowCount < 0) + ) { + throw new Error('Stored data row count is invalid.'); + } + const columns = args.columns?.map((column) => requiredText(column, 'column', 240)).slice(0, 64); + const sourceType = + args.format === 'csv' ? 'spreadsheet' : args.format === 'json' ? 'document' : 'note'; + const id = nodeslideStableId('source', args.deckId, sourceType, title, args.contentDigest); + const existing = await ctx.db + .query('nodeslide_sources') + .withIndex('by_stable_id', (query) => query.eq('id', id)) + .unique(); + const now = Date.now(); + const previewLabel = args.previewTruncated + ? 'Bounded model preview; exact full-file digest retained' + : 'Complete model-readable content'; + const source = { + id, + deckId: args.deckId, + title, + sourceType, + retrievedAt: existing?.retrievedAt ?? now, + citation: `Uploaded file: ${title}\n${previewLabel}\n${preview}`, + license: 'User supplied', + format: args.format, + contentDigest: args.contentDigest, + byteSize: args.byteSize, + ...(args.rowCount !== undefined ? { rowCount: args.rowCount } : {}), + ...(columns?.length ? { columns } : {}), + retention: 'until_deleted' as const, + status: 'ready' as const, + lastRefreshedAt: now, + } as const; + if (existing) await ctx.db.patch(existing._id, source); + else { + const sourceCount = ( + await ctx.db + .query('nodeslide_sources') + .withIndex('by_deck', (query) => query.eq('deckId', args.deckId)) + .collect() + ).length; + if (sourceCount >= 64) throw new Error('This deck has reached its source attachment limit.'); + await ctx.db.insert('nodeslide_sources', source); + } + await ensureNodeSlideSourceRevision(ctx, { + source, + ownerAccessKey: args.ownerAccessKey, + contentDigest: source.contentDigest, + createdAt: source.lastRefreshedAt, + }); return { id, kind: 'source' as const, label: `Source: ${title}` }; }, }); @@ -513,7 +939,10 @@ function agentMessageToolSpanKey(runId: string, toolName: string, startTime: num } function projectAgentMessageToolActivity( - message: Pick, 'createdAt' | 'role' | 'runId' | 'toolName'>, + message: Pick< + Doc<'nodeslide_agent_messages'>, + 'agentRole' | 'createdAt' | 'role' | 'runId' | 'toolName' + >, run: Doc<'nodeslide_agent_runs'> | undefined, span: Doc<'nodeslide_agent_spans'> | undefined, ): NodeSlideAgentToolActivity | undefined { @@ -536,6 +965,16 @@ function projectAgentMessageToolActivity( if (run && activeStatus && run.status === activeStatus && run.updatedAt === message.createdAt) { return { state: 'input-available' }; } + // Role handoffs are durable tool rows even when the underlying stage is one sequential model + // turn rather than a separately instrumented tool span. Their state follows the durable run + // clock; no parallel or independent execution is inferred here. + if (message.agentRole) { + return run && + run.updatedAt <= message.createdAt && + ['queued', 'researching', 'planning', 'validating'].includes(run.status) + ? { state: 'input-available' } + : { state: run?.status === 'failed' ? 'output-error' : 'output-available' }; + } return undefined; } @@ -587,6 +1026,145 @@ export const listAgentTelemetryPage = query({ }, }); +/** + * Owner-only capture index for one run. This intentionally returns counts and binding metadata, + * never storage IDs or signed URLs. The selected detail query resolves one attachment at a time. + */ +export const listEvidenceCaptureSummaries = query({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + runId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args) => { + await requireOwnerAccess(ctx, args.deckId, args.ownerAccessKey); + const run = await ctx.db + .query('nodeslide_agent_runs') + .withIndex('by_stable_id', (query) => query.eq('id', args.runId)) + .unique(); + if (!run || run.deckId !== args.deckId) throw new Error('Agent run not found.'); + const limit = Math.max( + 1, + Math.min(NODESLIDE_EVIDENCE_CAPTURE_LIMIT_PER_RUN, Math.floor(args.limit ?? 20)), + ); + const captures = await ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_run_created', (query) => query.eq('runId', args.runId)) + .order('desc') + .take(limit); + const sources = await ctx.db + .query('nodeslide_sources') + .withIndex('by_deck', (query) => query.eq('deckId', args.deckId)) + .collect(); + const sourceTitles = new Map(sources.map((source) => [source.id, source.title] as const)); + const revisionTitles = new Map( + ( + await Promise.all( + [ + ...new Set( + captures.flatMap((capture) => + capture.sourceRevisionId ? [capture.sourceRevisionId] : [], + ), + ), + ].map((revisionId) => + ctx.db + .query('nodeslide_source_revisions') + .withIndex('by_stable_id', (query) => query.eq('id', revisionId)) + .unique(), + ), + ) + ).flatMap((revision) => + revision && revision.deckId === args.deckId + ? ([[revision.id, revision.title]] as const) + : [], + ), + ); + const now = Date.now(); + return captures.map(({ _id, _creationTime, ...capture }) => ({ + ...capture, + sourceTitle: + (capture.sourceRevisionId ? revisionTitles.get(capture.sourceRevisionId) : undefined) ?? + sourceTitles.get(capture.sourceId) ?? + 'Unavailable source', + status: + capture.expiresAt !== undefined && capture.expiresAt <= now + ? ('expired' as const) + : capture.status, + })); + }, +}); + +/** Resolve storage URLs only for the single capture the owner explicitly opened. */ +export const getEvidenceCaptureDetail = query({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + captureId: v.string(), + }, + handler: async (ctx, args) => { + await requireOwnerAccess(ctx, args.deckId, args.ownerAccessKey); + const capture = await ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_stable_id', (query) => query.eq('id', args.captureId)) + .unique(); + if (!capture || capture.deckId !== args.deckId) return null; + const source = await ctx.db + .query('nodeslide_sources') + .withIndex('by_stable_id', (query) => query.eq('id', capture.sourceId)) + .unique(); + const captureSourceRevisionId = capture.sourceRevisionId; + const sourceRevision = captureSourceRevisionId + ? await ctx.db + .query('nodeslide_source_revisions') + .withIndex('by_stable_id', (query) => query.eq('id', captureSourceRevisionId)) + .unique() + : null; + const validSource = source?.deckId === args.deckId ? source : null; + const validRevision = + sourceRevision?.deckId === args.deckId && sourceRevision.sourceId === capture.sourceId + ? sourceRevision + : null; + if (!validSource && !validRevision) return null; + const steps = await ctx.db + .query('nodeslide_evidence_steps') + .withIndex('by_capture_sequence', (query) => query.eq('captureId', capture.id)) + .take(NODESLIDE_EVIDENCE_STEP_LIMIT_PER_CAPTURE); + const expired = capture.expiresAt !== undefined && capture.expiresAt <= Date.now(); + const resolvedSteps = await Promise.all( + steps.map(async ({ _id, _creationTime, screenshotStorageId, pdfStorageId, ...step }) => { + const attachment = expired + ? undefined + : screenshotStorageId + ? { + kind: 'screenshot' as const, + url: await ctx.storage.getUrl(screenshotStorageId), + ...(step.box ? { box: step.box } : {}), + } + : pdfStorageId + ? { + kind: 'pdf' as const, + url: await ctx.storage.getUrl(pdfStorageId), + ...(step.box ? { box: step.box } : {}), + ...(step.box?.page !== undefined ? { page: step.box.page } : {}), + } + : undefined; + return { + ...step, + ...(attachment?.url ? { attachment } : {}), + }; + }), + ); + const { _id, _creationTime, ...captureData } = capture; + return { + ...captureData, + sourceTitle: validRevision?.title ?? validSource?.title ?? 'Unavailable source', + status: expired ? ('expired' as const) : capture.status, + steps: resolvedSteps, + }; + }, +}); + export const cancelAgentRun = mutation({ args: { deckId: v.string(), ownerAccessKey: v.string(), runId: v.string() }, handler: async (ctx, args) => { @@ -1876,6 +2454,373 @@ export const markAgentTelemetryExportInternal = internalMutation({ }, }); +export const recordEvidenceCaptureInternal = internalMutation({ + args: { + id: v.string(), + deckId: v.string(), + ownerAccessKey: v.string(), + runId: v.string(), + parentSpanId: v.string(), + sourceId: v.string(), + url: v.string(), + goal: v.string(), + provider: v.string(), + status: v.union(v.literal('ready'), v.literal('failed')), + error: v.optional(v.string()), + contentDigest: v.optional(v.string()), + startedAt: v.number(), + completedAt: v.number(), + steps: v.array( + v.object({ + phase: v.string(), + label: v.string(), + status: v.union(v.literal('ok'), v.literal('warning'), v.literal('error')), + detail: v.optional(v.string()), + screenshotStorageId: v.optional(v.id('_storage')), + pdfStorageId: v.optional(v.id('_storage')), + box: v.optional(nodeslideEvidenceBoxValidator), + regionScope: v.union(v.literal('source'), v.literal('claim')), + selector: v.optional(v.string()), + quote: v.optional(v.string()), + viewport: v.optional(nodeslideEvidenceViewportValidator), + contentDigest: v.optional(v.string()), + startedAt: v.number(), + completedAt: v.optional(v.number()), + }), + ), + }, + handler: async (ctx, args) => { + await requireOwnerAccess(ctx, args.deckId, args.ownerAccessKey); + const run = await ctx.db + .query('nodeslide_agent_runs') + .withIndex('by_stable_id', (query) => query.eq('id', args.runId)) + .unique(); + if (!run || run.deckId !== args.deckId) throw new Error('Agent run not found.'); + const parent = await ctx.db + .query('nodeslide_agent_spans') + .withIndex('by_stable_id', (query) => + query.eq('id', nodeslideStableId('agent_span', args.runId, args.parentSpanId)), + ) + .unique(); + if (!parent || parent.deckId !== args.deckId || parent.runId !== args.runId) { + throw new Error('Evidence capture parent span is invalid.'); + } + const source = await ctx.db + .query('nodeslide_sources') + .withIndex('by_stable_id', (query) => query.eq('id', args.sourceId)) + .unique(); + if (!source || source.deckId !== args.deckId || source.url !== args.url) { + throw new Error('Evidence capture source binding is invalid.'); + } + const existing = await ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_stable_id', (query) => query.eq('id', args.id)) + .unique(); + if (existing) { + if ( + existing.deckId !== args.deckId || + existing.runId !== args.runId || + existing.sourceId !== args.sourceId + ) { + throw new Error('Evidence capture idempotency binding is invalid.'); + } + return { created: false, captureId: existing.id, spanId: existing.spanId }; + } + const captureCount = await ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_run_created', (query) => query.eq('runId', args.runId)) + .take(NODESLIDE_EVIDENCE_CAPTURE_LIMIT_PER_RUN + 1); + if (captureCount.length >= NODESLIDE_EVIDENCE_CAPTURE_LIMIT_PER_RUN) { + throw new Error('This run has reached its visual evidence capture limit.'); + } + if (args.steps.length < 1 || args.steps.length > NODESLIDE_EVIDENCE_STEP_LIMIT_PER_CAPTURE) { + throw new Error('Evidence capture step count is invalid.'); + } + const startedAt = Math.max(0, Math.floor(args.startedAt)); + const completedAt = Math.max(startedAt, Math.floor(args.completedAt)); + const traceId = run.otelTraceId ?? agentTraceId(args.deckId, args.runId); + if (parent.traceId !== traceId) throw new Error('Evidence capture trace binding is invalid.'); + const sourceRevision = await ensureNodeSlideSourceRevision(ctx, { + source, + ownerAccessKey: args.ownerAccessKey, + ...(args.contentDigest ? { contentDigest: args.contentDigest } : {}), + createdAt: completedAt, + }); + let telemetrySequence = run.nextTelemetrySequence ?? 3; + const preparedSteps = args.steps.map((step, index) => { + if (step.screenshotStorageId && step.pdfStorageId) { + throw new Error('An evidence step cannot contain both screenshot and PDF storage.'); + } + if (step.box && !isNormalizedEvidenceBox(step.box)) { + throw new Error('Evidence capture box must use normalized coordinates.'); + } + if ( + step.viewport && + (!Number.isInteger(step.viewport.width) || + !Number.isInteger(step.viewport.height) || + step.viewport.width <= 0 || + step.viewport.height <= 0 || + step.viewport.width > 10_000 || + step.viewport.height > 10_000) + ) { + throw new Error('Evidence capture viewport is invalid.'); + } + const sequence = telemetrySequence; + telemetrySequence += 2; + const phase = requiredText(step.phase, 'evidence phase', 80); + const label = requiredText(step.label, 'evidence label', 300); + const stepStartedAt = Math.max(startedAt, Math.floor(step.startedAt)); + const stepCompletedAt = Math.max( + stepStartedAt, + Math.floor(step.completedAt ?? stepStartedAt), + ); + const spanId = agentSpanId(traceId, `capture_${args.id}_${index}`, sequence); + const box = + step.pdfStorageId && + args.provider === 'nodeslide-source-snapshot/v1' && + step.box?.page === 1 && + step.box.pageCount === undefined + ? { ...step.box, pageCount: 1 } + : step.box; + const attachmentKind = step.screenshotStorageId + ? ('screenshot' as const) + : step.pdfStorageId + ? ('pdf' as const) + : undefined; + const attachmentDigest = + attachmentKind && /^sha256:[0-9a-f]{64}$/.test(step.contentDigest ?? '') + ? step.contentDigest + : undefined; + const evidenceStepDigest = nodeslideContentDigest( + stableJson({ + captureId: args.id, + sequence: index + 1, + phase, + label, + status: step.status, + detail: step.detail, + attachmentKind, + attachmentDigest, + box, + regionScope: step.regionScope, + selector: step.selector, + quote: step.quote, + viewport: step.viewport, + contentDigest: step.contentDigest, + startedAt: stepStartedAt, + completedAt: stepCompletedAt, + }), + ); + return { + ...step, + phase, + label, + ...(box ? { box } : {}), + ...(attachmentKind ? { attachmentKind } : {}), + ...(attachmentDigest ? { attachmentDigest } : {}), + evidenceStepDigest, + sequence, + spanId, + startedAt: stepStartedAt, + completedAt: stepCompletedAt, + }; + }); + const captureSpanId = preparedSteps[0]?.spanId; + if (!captureSpanId) throw new Error('Evidence capture did not produce a span.'); + const now = Date.now(); + for (const [index, step] of preparedSteps.entries()) { + await ctx.db.insert('nodeslide_agent_spans', { + id: nodeslideStableId('agent_span', args.runId, step.spanId), + deckId: args.deckId, + runId: args.runId, + traceId, + spanId: step.spanId, + parentSpanId: args.parentSpanId, + name: `Capture ${step.phase}`, + operationName: `evidence.${step.phase.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`, + kind: 'client', + status: step.status === 'error' ? 'error' : 'ok', + startTime: step.startedAt, + endTime: step.completedAt, + durationMs: Math.max(0, step.completedAt - step.startedAt), + provider: args.provider, + toolName: 'capture_source', + sourceIds: [args.sourceId], + attributes: [ + { key: 'nodeslide.evidence.capture_id', value: args.id }, + { key: 'nodeslide.evidence.source_id', value: args.sourceId }, + { key: 'nodeslide.evidence.step', value: index + 1 }, + { key: 'nodeslide.evidence.has_screenshot', value: Boolean(step.screenshotStorageId) }, + { key: 'nodeslide.evidence.has_pdf', value: Boolean(step.pdfStorageId) }, + ...(step.contentDigest + ? [{ key: 'nodeslide.evidence.content_digest', value: step.contentDigest }] + : []), + ], + sequence: step.sequence, + createdAt: now, + updatedAt: now, + }); + await ctx.db.insert('nodeslide_agent_events', { + id: nodeslideStableId('agent_event', args.runId, String(step.sequence + 1), args.id), + deckId: args.deckId, + runId: args.runId, + traceId, + spanId: step.spanId, + name: step.status === 'error' ? 'evidence.capture.failed' : 'evidence.capture.attached', + severity: step.status === 'error' ? 'error' : step.status === 'warning' ? 'warn' : 'info', + timestamp: step.completedAt, + body: + step.status === 'error' + ? 'Visual evidence capture failed; the source citation remains available.' + : 'Visual evidence was attached to this exact retrieval span.', + attributes: [ + { key: 'nodeslide.evidence.capture_id', value: args.id }, + { key: 'nodeslide.evidence.source_id', value: args.sourceId }, + ], + sequence: step.sequence + 1, + }); + await ctx.db.insert('nodeslide_evidence_steps', { + id: nodeslideStableId('evidence_step', args.id, String(index)), + captureId: args.id, + deckId: args.deckId, + runId: args.runId, + traceId, + spanId: step.spanId, + sequence: index + 1, + phase: step.phase, + label: step.label, + status: step.status, + ...(step.detail ? { detail: requiredText(step.detail, 'evidence detail', 1000) } : {}), + ...(step.screenshotStorageId + ? { screenshotStorageId: step.screenshotStorageId, attachmentKind: 'screenshot' as const } + : {}), + ...(step.pdfStorageId + ? { pdfStorageId: step.pdfStorageId, attachmentKind: 'pdf' as const } + : {}), + ...(step.box ? { box: step.box } : {}), + regionScope: step.regionScope, + ...(step.selector + ? { selector: requiredText(step.selector, 'evidence selector', 300) } + : {}), + ...(step.quote ? { quote: requiredText(step.quote, 'evidence quote', 1000) } : {}), + ...(step.viewport ? { viewport: step.viewport } : {}), + ...(step.contentDigest ? { contentDigest: step.contentDigest.slice(0, 180) } : {}), + ...(step.attachmentDigest ? { attachmentDigest: step.attachmentDigest } : {}), + evidenceStepDigest: step.evidenceStepDigest, + startedAt: step.startedAt, + completedAt: step.completedAt, + createdAt: now, + }); + } + const screenshotCount = preparedSteps.filter((step) => step.screenshotStorageId).length; + const pdfCount = preparedSteps.filter((step) => step.pdfStorageId).length; + const captureDigest = nodeslideContentDigest( + stableJson({ + deckId: args.deckId, + runId: args.runId, + traceId, + parentSpanId: args.parentSpanId, + sourceRevisionId: sourceRevision.id, + sourceRevisionDigest: sourceRevision.revisionDigest, + url: source.url ?? args.url, + goal: args.goal, + provider: args.provider, + status: args.status, + error: args.error, + contentDigest: args.contentDigest, + steps: preparedSteps.map((step) => ({ + sequence: step.sequence, + evidenceStepDigest: step.evidenceStepDigest, + })), + startedAt, + completedAt, + }), + ); + await ctx.db.insert('nodeslide_evidence_captures', { + id: args.id, + deckId: args.deckId, + runId: args.runId, + traceId, + spanId: captureSpanId, + parentSpanId: args.parentSpanId, + sourceId: args.sourceId, + sourceRevisionId: sourceRevision.id, + sourceRevisionDigest: sourceRevision.revisionDigest, + captureDigest, + url: source.url ?? args.url, + goal: requiredText(args.goal, 'evidence goal', 500), + provider: requiredText(args.provider, 'evidence provider', 80), + status: args.status, + ...(args.error ? { error: requiredText(args.error, 'evidence error', 500) } : {}), + ...(args.contentDigest ? { contentDigest: args.contentDigest.slice(0, 180) } : {}), + stepCount: preparedSteps.length, + screenshotCount, + pdfCount, + createdAt: startedAt, + completedAt, + expiresAt: completedAt + NODESLIDE_EVIDENCE_CAPTURE_TTL_MS, + }); + const parentEndTime = Math.max(parent.endTime ?? parent.startTime, completedAt); + await ctx.db.patch(parent._id, { + endTime: parentEndTime, + durationMs: Math.max(0, parentEndTime - parent.startTime), + updatedAt: now, + }); + await ctx.db.patch(run._id, { + nextTelemetrySequence: telemetrySequence, + updatedAt: Math.max(run.updatedAt, completedAt), + lastHeartbeatAt: Math.max(run.lastHeartbeatAt ?? 0, completedAt), + }); + return { created: true, captureId: args.id, spanId: captureSpanId }; + }, +}); + +/** Removes expired binary attachments while retaining their auditable trace metadata and digest. */ +export const pruneExpiredEvidenceCapturesInternal = internalMutation({ + args: {}, + handler: async (ctx) => { + const now = Date.now(); + const captures = await ctx.db + .query('nodeslide_evidence_captures') + .withIndex('by_expiry', (query) => query.lte('expiresAt', now)) + .take(20); + let deletedAttachments = 0; + for (const capture of captures) { + const steps = await ctx.db + .query('nodeslide_evidence_steps') + .withIndex('by_capture_sequence', (query) => query.eq('captureId', capture.id)) + .take(NODESLIDE_EVIDENCE_STEP_LIMIT_PER_CAPTURE + 1); + if (steps.length > NODESLIDE_EVIDENCE_STEP_LIMIT_PER_CAPTURE) { + throw new Error('Expired evidence capture exceeds its bounded step limit.'); + } + for (const step of steps) { + if (step.screenshotStorageId) { + await ctx.storage.delete(step.screenshotStorageId); + deletedAttachments += 1; + } + if (step.pdfStorageId) { + await ctx.storage.delete(step.pdfStorageId); + deletedAttachments += 1; + } + if (step.screenshotStorageId || step.pdfStorageId || step.attachmentKind) { + await ctx.db.patch(step._id, { + screenshotStorageId: undefined, + pdfStorageId: undefined, + attachmentKind: undefined, + }); + } + } + await ctx.db.patch(capture._id, { + status: 'expired', + screenshotCount: 0, + pdfCount: 0, + expiresAt: undefined, + }); + } + return { captures: captures.length, deletedAttachments }; + }, +}); + export const advanceAgentRunInternal = internalMutation({ args: { deckId: v.string(), @@ -1898,6 +2843,22 @@ export const advanceAgentRunInternal = internalMutation({ toolName: v.optional(v.string()), toolCallId: v.optional(v.string()), parentMessageId: v.optional(v.string()), + agentRole: v.optional( + v.union( + v.literal('planner'), + v.literal('executor'), + v.literal('researcher'), + v.literal('validator'), + v.literal('analyst'), + v.literal('storyteller'), + v.literal('designer'), + v.literal('fact_checker'), + v.literal('reviewer'), + ), + ), + branchId: v.optional(v.string()), + branchLabel: v.optional(v.string()), + parallelGroupId: v.optional(v.string()), sourceIds: v.optional(v.array(v.string())), memoryIds: v.optional(v.array(v.string())), memoryDigests: v.optional(v.array(v.string())), @@ -2023,11 +2984,13 @@ export const advanceAgentRunInternal = internalMutation({ runId: args.runId, }); } + let messageId: string | undefined; if (args.message) { const message = requiredText(args.message, 'run message', 4000); const role = args.role ?? 'system'; + messageId = nodeslideStableId('agent_message', args.runId, role, String(now), message); await ctx.db.insert('nodeslide_agent_messages', { - id: nodeslideStableId('agent_message', args.runId, role, String(now), message), + id: messageId, deckId: args.deckId, runId: args.runId, role, @@ -2039,11 +3002,19 @@ export const advanceAgentRunInternal = internalMutation({ ...(args.parentMessageId ? { parentMessageId: requiredText(args.parentMessageId, 'parent message id', 180) } : {}), + ...(args.agentRole ? { agentRole: args.agentRole } : {}), + ...(args.branchId ? { branchId: requiredText(args.branchId, 'branch id', 120) } : {}), + ...(args.branchLabel + ? { branchLabel: requiredText(args.branchLabel, 'branch label', 120) } + : {}), + ...(args.parallelGroupId + ? { parallelGroupId: requiredText(args.parallelGroupId, 'parallel group id', 120) } + : {}), ...(args.sourceIds ? { sourceIds: args.sourceIds.slice(0, 32) } : {}), createdAt: now, }); } - return args.runId; + return { runId: args.runId, traceId, spanId: phaseSpanId, messageId }; }, }); @@ -2201,6 +3172,12 @@ export const attachWebSourcesInternal = internalMutation({ }; if (existing) await ctx.db.patch(existing._id, source); else await ctx.db.insert('nodeslide_sources', source); + await ensureNodeSlideSourceRevision(ctx, { + source, + ownerAccessKey: args.ownerAccessKey, + contentDigest: source.contentDigest, + createdAt: now, + }); refs.push({ id, kind: 'source', label: `Web: ${title}` }); } return refs; @@ -2469,6 +3446,17 @@ export const proposeAgentPatchInternal = internalMutation({ throw new Error('Agent shadow comparison authorization binding is invalid.'); } const proposal = await persistProposal(ctx, { ...args, source: 'agent' }); + if (proposal.patch.status === 'ready') { + await persistNodeSlideClaimEvidenceReceipts(ctx, { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + patchId: proposal.patch.id, + traceId: args.traceId, + ...(durableJob?.conversationRunId ? { runId: durableJob.conversationRunId } : {}), + operations: args.operations, + createdAt: proposal.patch.createdAt, + }); + } if (durableJob) { if (proposal.patch.status !== 'ready' || !proposal.patch.candidateDigest) { throw new Error('Durable NodeSlide proposal did not produce a reviewable candidate.'); @@ -3145,6 +4133,34 @@ async function commitPatch( await ctx.db.insert('nodeslide_validations', validation); if (args.linkedCommentId) await resolveLinkedComment(ctx, args.linkedCommentId, args.deckId, id, now); + const receiptJobId = args.jobId; + const receiptJob = receiptJobId + ? await ctx.db + .query('nodeslide_agent_jobs') + .withIndex('by_stable_id', (query) => query.eq('id', receiptJobId)) + .unique() + : null; + if ( + receiptJob && + receiptJob.resultDeckId !== undefined && + receiptJob.resultDeckId !== args.deckId + ) { + throw new Error('Claim evidence receipt job crossed its deck boundary.'); + } + const receiptOwnerAccessKey = delegatedAuthority + ? (deckRow.ownerAccessKey ?? '') + : args.ownerAccessKey; + if (receiptOwnerAccessKey) { + await persistNodeSlideClaimEvidenceReceipts(ctx, { + deckId: args.deckId, + ownerAccessKey: receiptOwnerAccessKey, + patchId: id, + ...(args.traceId ? { traceId: args.traceId } : {}), + ...(receiptJob?.conversationRunId ? { runId: receiptJob.conversationRunId } : {}), + operations: args.operations, + createdAt: now, + }); + } await finishPatchTrace(ctx, accepted, now, 'completed', validation, delegatedAuthority); return { patch: accepted, @@ -3775,6 +4791,29 @@ function validateAnchor(snapshot: DeckSnapshot, anchor: CommentAnchor) { } } +function isNormalizedEvidenceBox(box: NodeSlideEvidenceBox & { pageCount?: number }): boolean { + return ( + Number.isFinite(box.x) && + Number.isFinite(box.y) && + Number.isFinite(box.w) && + Number.isFinite(box.h) && + box.x >= 0 && + box.y >= 0 && + box.w > 0 && + box.h > 0 && + box.x + box.w <= 1 && + box.y + box.h <= 1 && + (box.page === undefined || + (Number.isInteger(box.page) && box.page > 0 && box.page <= 10_000)) && + (box.pageCount === undefined || + (Number.isInteger(box.pageCount) && + box.pageCount > 0 && + box.pageCount <= 100_000 && + box.page !== undefined && + box.page <= box.pageCount)) + ); +} + function requiredText(value: string, label: string, max: number): string { const clean = value.replace(/\s+/g, ' ').trim(); if (!clean) throw new Error(`${label} is required.`); diff --git a/convex/nodeslideAgent.ts b/convex/nodeslideAgent.ts index 33bd8bc..4ac99f5 100644 --- a/convex/nodeslideAgent.ts +++ b/convex/nodeslideAgent.ts @@ -12,6 +12,7 @@ import { type PatchOperation, nodeSlideAgentModel, } from '../shared/nodeslide'; +import { createNodeSlideAuthoringWorkflow } from '../shared/nodeslideAuthoringWorkflow'; import { nodeSlideDurableDigest } from '../shared/nodeslideDurableSession'; import { type NodeSlideRunBudgetInput, @@ -53,6 +54,10 @@ import { NODESLIDE_EDIT_SHADOW_ADAPTER_VERSION, planNodeSlideEditShadow, } from './lib/nodeslideEditShadowPlanner'; +import { + captureNodeSlideWebEvidence, + createNodeSlideSourceSnapshotPdf, +} from './lib/nodeslideEvidenceCapture'; import { executionTraceFromDeckRepl } from './lib/nodeslideExecutionTrace'; import { nodeslideContentDigest, nodeslideEventId, nodeslideStableId } from './lib/nodeslideIds'; import { nodeSlideJobRequestDigest } from './lib/nodeslideJobState'; @@ -60,6 +65,7 @@ import { nodeSlideCreateJobRequestFromArgs, nodeSlideEditProposalJobRequestFromArgs, } from './lib/nodeslideJobValidators'; +import { validateNodeSlideLiveEditWithDeckRepl } from './lib/nodeslideLiveDeckRepl'; import { nodeSlideMemoryUse } from './lib/nodeslideMemoryPolicy'; import { NODESLIDE_EDIT_MODEL, callNodeSlideFreeJson } from './lib/nodeslideProvider'; import { @@ -134,6 +140,7 @@ export const proposeEdit = action({ providerModel: v.optional(nodeslideAgentModelValidator), providerEffort: v.optional(nodeslideReasoningEffortValidator), providerConsent: v.optional(v.string()), + maxCostUsd: v.optional(v.number()), idempotencyKey: v.optional(v.string()), webResearch: v.optional(v.boolean()), webResearchConsent: v.optional(v.string()), @@ -191,7 +198,7 @@ export const proposeEdit = action({ if (instruction.length > 4000) throw new Error('NodeSlide edit instruction exceeds 4000 characters.'); const spendConstraint = parseNodeSlideSpendConstraint(instruction); - const runBudget = nodeSlideRunBudgetForConstraint(spendConstraint); + const runBudget = nodeSlideRunBudgetForConstraint(spendConstraint, args.maxCostUsd); if ((args.commandId ?? 'edit') !== 'edit') { throw publicAgentError( 'invalid_request', @@ -216,7 +223,7 @@ export const proposeEdit = action({ throw error; } if (args.webResearch) { - if (spendConstraint) { + if (spendConstraint || args.maxCostUsd !== undefined) { throw publicAgentError( 'invalid_request', 'A hard dollar ceiling cannot include unpriced web-search egress yet. Turn Web off or remove the run spend ceiling.', @@ -317,8 +324,9 @@ export const proposeEdit = action({ try { let webSourceIds: string[] = []; let webProvidersUsed: string[] = []; + let handoffParentMessageId: string | undefined; if (args.webResearch) { - await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { + const researchReceipt = await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, runId, @@ -326,7 +334,11 @@ export const proposeEdit = action({ message: `Searching the web for: ${instruction}`, role: 'tool', toolName: 'web_search', + agentRole: 'researcher', + branchId: 'presentation-research', + branchLabel: 'Evidence research', }); + handoffParentMessageId = researchReceipt?.messageId; const configured = configuredSearchProviders(); if (configured.length === 0) { throw publicAgentError( @@ -345,18 +357,19 @@ export const proposeEdit = action({ references: search.references, }); } + const webSourceInputs = search.references + .filter((reference) => reference.mediaType === 'website') + .slice(0, 10) + .map((reference) => ({ + title: reference.title, + url: reference.sourceUrl, + snippet: reference.snippet || `Search result from ${reference.provider}.`, + provider: reference.provider, + })); const webRefs = await ctx.runMutation(nodeslideInternal.attachWebSourcesInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, - sources: search.references - .filter((reference) => reference.mediaType === 'website') - .slice(0, 10) - .map((reference) => ({ - title: reference.title, - url: reference.sourceUrl, - snippet: reference.snippet || `Search result from ${reference.provider}.`, - provider: reference.provider, - })), + sources: webSourceInputs, }); webSourceIds = webRefs.map((reference: { id: string }) => reference.id); if (webSourceIds.length === 0) { @@ -365,27 +378,55 @@ export const proposeEdit = action({ 'The web search returned no usable sources. No proposal was created.', ); } - await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { - deckId: args.deckId, - ownerAccessKey: args.ownerAccessKey, - runId, - status: 'planning', - message: `Retained ${webSourceIds.length} web sources from ${webProvidersUsed.join(', ') || configured.join(', ')}.`, - role: 'tool', - toolName: 'source_snapshot', - sourceIds: webSourceIds, - }); + const sourceSnapshotReceipt = await ctx.runMutation( + nodeslideInternal.advanceAgentRunInternal, + { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + runId, + status: 'planning', + message: `Retained ${webSourceIds.length} web sources from ${webProvidersUsed.join(', ') || configured.join(', ')}.`, + role: 'tool', + toolName: 'source_snapshot', + agentRole: 'researcher', + branchId: 'presentation-research', + branchLabel: 'Evidence research', + ...(handoffParentMessageId ? { parentMessageId: handoffParentMessageId } : {}), + sourceIds: webSourceIds, + }, + ); + handoffParentMessageId = sourceSnapshotReceipt?.messageId ?? handoffParentMessageId; + if (sourceSnapshotReceipt?.spanId) { + await captureWebSourcesBestEffort(ctx, { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + runId, + parentSpanId: sourceSnapshotReceipt.spanId, + sources: pairNodeSlideStoredWebSources({ + deckId: args.deckId, + inputs: webSourceInputs, + references: webRefs, + }), + }); + } workspace = (await ctx.runQuery(nodeslideInternal.getAgentContextInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, })) as NodeSlideWorkspace; } else { - await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { + const researchReceipt = await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, runId, status: 'planning', + message: 'Reviewed the scoped deck context and available source records.', + role: 'tool', + toolName: 'delegate_researcher', + agentRole: 'researcher', + branchId: 'presentation-research', + branchLabel: 'Context research', }); + handoffParentMessageId = researchReceipt?.messageId; } const memories: NodeSlideAgentMemory[] = args.memoryMode === 'relevant' @@ -400,7 +441,7 @@ export const proposeEdit = action({ (memory) => nodeSlideMemoryUse(memory) === 'standing_instruction', ).length; const retrievedMemoryCount = memories.length - standingInstructionCount; - await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { + const memoryReceipt = await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, runId, @@ -409,9 +450,14 @@ export const proposeEdit = action({ message: `Loaded ${standingInstructionCount} explicit standing instruction${standingInstructionCount === 1 ? '' : 's'} and ${retrievedMemoryCount} relevant retrieved memor${retrievedMemoryCount === 1 ? 'y' : 'ies'} for this run.`, role: 'tool', toolName: 'memory_retrieval', + agentRole: 'analyst', + branchId: 'presentation-analysis', + branchLabel: 'Audience and evidence analysis', + ...(handoffParentMessageId ? { parentMessageId: handoffParentMessageId } : {}), memoryIds: memories.map((memory) => memory.id), memoryDigests: memories.map((memory) => memory.contentDigest), }); + handoffParentMessageId = memoryReceipt?.messageId ?? handoffParentMessageId; await ctx.runMutation(nodeslideMemoryInternal.markUsedInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, @@ -429,11 +475,24 @@ export const proposeEdit = action({ writeScope: args.scope, ...(requestedReadContext.length ? { requested: requestedReadContext } : {}), }); + const analysisReceipt = await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + runId, + status: 'planning', + message: `Analyzed ${readContext.slides.length} slide${readContext.slides.length === 1 ? '' : 's'}, ${readContext.elements.length} element${readContext.elements.length === 1 ? '' : 's'}, and ${readContext.sources.length} source${readContext.sources.length === 1 ? '' : 's'} within the authorized scope.`, + role: 'tool', + toolName: 'delegate_analyst', + agentRole: 'analyst', + branchId: 'presentation-analysis', + branchLabel: 'Audience and evidence analysis', + ...(handoffParentMessageId ? { parentMessageId: handoffParentMessageId } : {}), + }); + handoffParentMessageId = analysisReceipt?.messageId ?? handoffParentMessageId; const explicitlySuppliedEvidence = webSourceIds.length > 0 || (args.readContext ?? []).some((reference) => reference.kind === 'source'); - const requireFactualSourceBindings = - providerChoice.providerMode !== 'deterministic' && explicitlySuppliedEvidence; + const requireFactualSourceBindings = explicitlySuppliedEvidence; const traceContext = [ `Read context: ${readContext.slides.length} slide${readContext.slides.length === 1 ? '' : 's'}, ${readContext.elements.length} element${readContext.elements.length === 1 ? '' : 's'}, ${readContext.sources.length} source${readContext.sources.length === 1 ? '' : 's'}, ${readContext.comments.length} comment${readContext.comments.length === 1 ? '' : 's'}`, ...readContext.sources.map( @@ -443,6 +502,7 @@ export const proposeEdit = action({ requireFactualSourceBindings ? 'Evidence policy: factual text and chart output requires exact claim-level source bindings' : 'Evidence policy: no external evidence-grounded factual output requested', + `Run budget: hard ceiling $${runBudget.maxCostUsd ?? 1} USD`, ]; const request: NodeSlideEditPlanningRequest = { @@ -465,6 +525,20 @@ export const proposeEdit = action({ } : {}), }; + const storyReceipt = await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + runId, + status: 'planning', + message: 'Shaping the requested change into a coherent presentation narrative.', + role: 'tool', + toolName: 'delegate_storyteller', + agentRole: 'storyteller', + branchId: 'presentation-story', + branchLabel: 'Narrative structure', + ...(handoffParentMessageId ? { parentMessageId: handoffParentMessageId } : {}), + }); + handoffParentMessageId = storyReceipt?.messageId ?? handoffParentMessageId; const planningStartedAt = Date.now(); const scopedComment = scopedCommentId === undefined @@ -520,8 +594,42 @@ export const proposeEdit = action({ const baselineElapsedMs = boundedLaneElapsed(Date.now() - planningStartedAt); if (!baseline.ok) throw publicAgentError(baseline.code, baseline.message); - const finalOperations = baseline.operations; + let finalOperations = baseline.operations; + let liveDeckReplValidation: ReturnType | null = + null; + try { + liveDeckReplValidation = validateNodeSlideLiveEditWithDeckRepl({ + runId, + traceId: nodeslideStableId('trace_live_edit_repl', args.deckId, runId), + snapshot, + baseDeckVersion: args.baseDeckVersion, + baseSlideVersions: args.baseSlideVersions, + baseElementVersions: args.baseElementVersions, + scope: args.scope, + operations: baseline.operations, + }); + finalOperations = liveDeckReplValidation.operations; + } catch (error) { + throw publicAgentError( + 'invalid_request', + `The bounded executor rejected this candidate before review. ${agentRunErrorMessage(error)}`, + ); + } const boundSourceIds = nodeSlideOperationSourceIds(finalOperations); + const designReceipt = await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + runId, + status: 'planning', + message: `Translated the narrative into ${finalOperations.length} bounded slide operation${finalOperations.length === 1 ? '' : 's'}.`, + role: 'tool', + toolName: 'delegate_designer', + agentRole: 'designer', + branchId: 'presentation-design', + branchLabel: 'Slide design', + ...(handoffParentMessageId ? { parentMessageId: handoffParentMessageId } : {}), + }); + handoffParentMessageId = designReceipt?.messageId ?? handoffParentMessageId; const runBeforeValidation = await ctx.runQuery(nodeslideInternal.getAgentRunInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, @@ -530,14 +638,35 @@ export const proposeEdit = action({ if (runBeforeValidation?.status === 'cancelled') { throw publicAgentError('invalid_request', 'The agent run was cancelled before validation.'); } + if (liveDeckReplValidation.status === 'validated') { + const executorReceipt = await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + runId, + status: 'validating', + message: `Bounded executor inspected the deck, measured ${liveDeckReplValidation.inspectedSlideIds.length} touched slide${liveDeckReplValidation.inspectedSlideIds.length === 1 ? '' : 's'}, and validated the exact ${finalOperations.length}-operation review candidate.`, + role: 'tool', + toolName: 'deck_repl', + agentRole: 'executor', + branchId: 'presentation-execution', + branchLabel: 'Bounded deck execution', + ...(handoffParentMessageId ? { parentMessageId: handoffParentMessageId } : {}), + ...(boundSourceIds.length ? { sourceIds: boundSourceIds } : {}), + }); + handoffParentMessageId = executorReceipt?.messageId ?? handoffParentMessageId; + } await ctx.runMutation(nodeslideInternal.advanceAgentRunInternal, { deckId: args.deckId, ownerAccessKey: args.ownerAccessKey, runId, status: 'validating', - message: `Validating ${baseline.operations.length} proposed operation${baseline.operations.length === 1 ? '' : 's'} against scope, versions, and layout rules.`, + message: `Validated ${finalOperations.length} proposed operation${finalOperations.length === 1 ? '' : 's'} against scope, versions, evidence bindings, and layout rules.`, role: 'tool', toolName: 'candidate_validation', + agentRole: 'fact_checker', + branchId: 'presentation-fact-check', + branchLabel: 'Evidence and quality check', + ...(handoffParentMessageId ? { parentMessageId: handoffParentMessageId } : {}), ...(boundSourceIds.length ? { sourceIds: boundSourceIds } : {}), }); const summary = baseline.summary; @@ -667,12 +796,16 @@ export const proposeEdit = action({ ? 'Used deterministic bounded edit fallback' : `Parsed and validated ${requestedProviderLabel} JSON` : 'Produced deterministic bounded edit operations', + liveDeckReplValidation.status === 'validated' + ? `Ran bounded Deck REPL inspection and measurement across ${liveDeckReplValidation.inspectedSlideIds.length} touched slide${liveDeckReplValidation.inspectedSlideIds.length === 1 ? '' : 's'} before validating the exact review proposal` + : liveDeckReplValidation.status === 'skipped_high_cardinality' + ? `Used the established validator for a high-cardinality candidate above the ${liveDeckReplValidation.operationLimit}-operation Deck REPL ceiling` + : 'Used the established linked-comment validator because comment anchors live outside the immutable Deck REPL snapshot', 'Persisted proposal and human-readable trace atomically', ], - sourceBindingPolicy: - requireFactualSourceBindings && baseline.receipt.origin === 'free_route' - ? 'required_external_evidence' - : 'not_applicable', + sourceBindingPolicy: requireFactualSourceBindings + ? 'required_external_evidence' + : 'not_applicable', authorizedSourceIds: readContext.sources.map((source) => source.id), ...traceAttribution, }); @@ -685,6 +818,9 @@ export const proposeEdit = action({ traceId, message: `${summary} Review the validated proposal before it can change the deck.`, role: 'assistant', + agentRole: 'reviewer', + branchId: 'presentation-review', + branchLabel: 'Human review', ...(boundSourceIds.length ? { sourceIds: boundSourceIds } : {}), }); if (!durableJob) return proposal; @@ -847,6 +983,9 @@ export const proposeExternalAgentEdit = action({ message: `Validating ${args.operations.length} ${submissionKind === 'external_agent' ? 'external-agent' : 'local-agent'} operation${args.operations.length === 1 ? '' : 's'} against scope, versions, and layout rules.`, role: 'tool', toolName: 'candidate_validation', + agentRole: 'fact_checker', + branchId: 'presentation-fact-check', + branchLabel: 'Evidence and quality check', }); const now = Date.now(); const patchId = nodeslideEventId('patch_external_agent', now, args.deckId, instruction); @@ -911,6 +1050,9 @@ export const proposeExternalAgentEdit = action({ traceId, message: `${summary} Review the validated proposal before it can change the deck.`, role: 'assistant', + agentRole: 'reviewer', + branchId: 'presentation-review', + branchLabel: 'Human review', }); return proposal; } catch (error) { @@ -1290,6 +1432,7 @@ export const createDeckFromBrief = action({ const slideCountInstruction = requestedSlideCount ? `Produce exactly ${requestedSlideCount} concise slides` : 'Produce 6–8 concise slides'; + const authoringWorkflow = createNodeSlideAuthoringWorkflow(brief); const fallbackSpec = deterministicBriefSpec(title, generationBrief); const runBudget = nodeSlideRunBudgetForConstraint( parseNodeSlideSpendConstraint([brief.prompt, ...brief.successCriteria].join('\n')), @@ -1304,6 +1447,7 @@ export const createDeckFromBrief = action({ attachments, requestedRoute: args.route, providerMode: providerChoice.providerMode, + authoringWorkflow, }), maxTokens: 5000, ...(providerChoice.providerMode !== 'deterministic' @@ -1445,10 +1589,10 @@ export const createDeckFromBrief = action({ selectedModelRoute?.provider === 'nebius' ? 'Nebius' : 'OpenRouter'; const traceSummary = providerChoice.providerMode === 'deterministic' - ? 'NodeSlide created the deck with its deterministic brief generator. The brief was not sent to an external model provider.' + ? `NodeSlide created the deck with its deterministic brief generator under ${authoringWorkflow.policyId} (${authoringWorkflow.digest}). The brief was not sent to an external model provider.` : providerSucceeded - ? `The user consented to send the full brief${attachments.length > 0 ? ` and ${attachments.length} uploaded data source${attachments.length === 1 ? '' : 's'}` : ''} to ${selectedProviderName}. The named ${selectedModelLabel} model supplied the narrative plan through pi-ai; NodeSlide normalized, persisted, and validated the deck deterministically.` - : `The user consented to send the full brief${attachments.length > 0 ? ' and uploaded data sources' : ''} to ${selectedProviderName}. NodeSlide used its deterministic fallback because ${provider?.ok === false ? provider.reason : `the ${selectedModelLabel} route was unavailable.`}`; + ? `The user consented to send the full brief${attachments.length > 0 ? ` and ${attachments.length} uploaded data source${attachments.length === 1 ? '' : 's'}` : ''} to ${selectedProviderName}. The named ${selectedModelLabel} model supplied the narrative plan through pi-ai under ${authoringWorkflow.policyId} (${authoringWorkflow.digest}); NodeSlide normalized, persisted, and validated the deck deterministically.` + : `The user consented to send the full brief${attachments.length > 0 ? ' and uploaded data sources' : ''} to ${selectedProviderName}. NodeSlide used its deterministic fallback under ${authoringWorkflow.policyId} (${authoringWorkflow.digest}) because ${provider?.ok === false ? provider.reason : `the ${selectedModelLabel} route was unavailable.`}`; return await ctx.runMutation(nodeslideInternal.createFromBriefInternal, { deckId, projectId, @@ -1499,8 +1643,29 @@ export const createDeckFromBrief = action({ function nodeSlideRunBudgetForConstraint( constraint: ReturnType, + explicitMaxCostUsd?: number, ): NodeSlideRunBudgetInput { - return constraint ? { maxCostUsd: constraint.maxCostMicroUsd / 1_000_000 } : {}; + if ( + explicitMaxCostUsd !== undefined && + (!Number.isFinite(explicitMaxCostUsd) || explicitMaxCostUsd < 0 || explicitMaxCostUsd > 100) + ) { + throw publicAgentError( + 'invalid_request', + 'Run spend ceiling must be a finite USD amount from $0 through $100.', + ); + } + const instructionMaxCostUsd = constraint?.maxCostMicroUsd + ? constraint.maxCostMicroUsd / 1_000_000 + : constraint + ? 0 + : undefined; + if (explicitMaxCostUsd === undefined && instructionMaxCostUsd === undefined) return {}; + return { + maxCostUsd: Math.min( + explicitMaxCostUsd ?? Number.POSITIVE_INFINITY, + instructionMaxCostUsd ?? Number.POSITIVE_INFINITY, + ), + }; } function nodeSlideBudgetLedgerClient( @@ -1658,6 +1823,210 @@ async function appendNodeSlideWebJournalReceipt( }); } +interface NodeSlideWebSourceInput { + title: string; + url: string; + snippet: string; + provider: string; +} + +export interface NodeSlideStoredWebSource extends NodeSlideWebSourceInput { + sourceId: string; +} + +/** + * Rejoins mutation results to inputs by the same stable URL identity used by attachWebSourcesInternal. + * Invalid inputs can therefore be skipped without shifting every later source binding. + */ +export function pairNodeSlideStoredWebSources(args: { + deckId: string; + inputs: readonly NodeSlideWebSourceInput[]; + references: readonly { id: string }[]; +}): NodeSlideStoredWebSource[] { + const inputsBySourceId = new Map(); + for (const input of args.inputs) { + const url = normalizedStoredNodeSlideWebSourceUrl(input.url); + if (!url) continue; + const sourceId = nodeslideStableId('source_web', args.deckId, url); + inputsBySourceId.set(sourceId, { ...input, sourceId, url }); + } + return args.references.flatMap((reference) => { + const source = inputsBySourceId.get(reference.id); + return source ? [source] : []; + }); +} + +export function nodeSlideEvidenceAttachmentDigest(bytes: Uint8Array): string { + return nodeslideContentDigest(bytes); +} + +/** Deletes a just-stored attachment if its custody record cannot be committed, then rethrows. */ +export async function finalizeNodeSlideEvidenceRecord(args: { + storageId: TStorageId; + deleteStorage: (storageId: TStorageId) => Promise; + record: () => Promise; +}): Promise { + try { + return await args.record(); + } catch (recordError) { + try { + await args.deleteStorage(args.storageId); + } catch (cleanupError) { + throw new AggregateError( + [recordError, cleanupError], + 'Evidence custody recording failed and the orphaned attachment could not be deleted.', + ); + } + throw recordError; + } +} + +export async function captureWebSourcesBestEffort( + ctx: ActionCtx, + args: { + deckId: string; + ownerAccessKey: string; + runId: string; + parentSpanId: string; + sources: NodeSlideStoredWebSource[]; + }, +): Promise { + const apiKey = process.env['FIRECRAWL_API_KEY']?.trim(); + const targets = args.sources.slice(0, 3); + const captures = await Promise.allSettled( + targets.map(async (source) => ({ + source, + capture: apiKey ? await captureNodeSlideWebEvidence({ url: source.url, apiKey }) : null, + })), + ); + for (const result of captures) { + if (result.status === 'rejected') continue; + const { source, capture } = result.value; + const retrievedAt = Date.now(); + const snapshotPdf = createNodeSlideSourceSnapshotPdf({ + title: source.title, + url: source.url, + excerpt: source.snippet, + provider: source.provider, + retrievedAt, + }); + let storedAttachment: + | { + storageId: Awaited>; + kind: 'screenshot' | 'pdf'; + digest: string; + } + | undefined; + if (capture?.screenshot) { + const bytes = Uint8Array.from(capture.screenshot.bytes); + try { + storedAttachment = { + storageId: await ctx.storage.store( + new Blob([bytes.buffer], { type: capture.screenshot.mimeType }), + ), + kind: 'screenshot', + digest: nodeSlideEvidenceAttachmentDigest(bytes), + }; + } catch { + storedAttachment = undefined; + } + } + if (!storedAttachment) { + const bytes = Uint8Array.from(snapshotPdf.bytes); + try { + storedAttachment = { + storageId: await ctx.storage.store(new Blob([bytes.buffer], { type: 'application/pdf' })), + kind: 'pdf', + digest: nodeSlideEvidenceAttachmentDigest(bytes), + }; + } catch { + // Evidence remains additive when no attachment was stored; retained citations still work. + continue; + } + } + const contentDigest = storedAttachment.digest; + const captureId = nodeslideStableId( + 'evidence_capture', + args.runId, + source.sourceId, + contentDigest, + ); + const screenshotStorageId = + storedAttachment.kind === 'screenshot' ? storedAttachment.storageId : undefined; + const pdfStorageId = storedAttachment.kind === 'pdf' ? storedAttachment.storageId : undefined; + const screenshotViewport = screenshotStorageId ? capture?.screenshot?.viewport : undefined; + const screenshotBox = screenshotViewport ? { x: 0, y: 0, w: 1, h: 1 } : undefined; + await finalizeNodeSlideEvidenceRecord({ + storageId: storedAttachment.storageId, + deleteStorage: (storageId) => ctx.storage.delete(storageId), + record: () => + ctx.runMutation(nodeslideInternal.recordEvidenceCaptureInternal, { + id: captureId, + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + runId: args.runId, + parentSpanId: args.parentSpanId, + sourceId: source.sourceId, + url: source.url, + goal: `Preserve evidence for ${source.title}`, + provider: screenshotStorageId + ? (capture?.provider ?? 'firecrawl') + : 'nodeslide-source-snapshot/v1', + status: 'ready', + contentDigest, + startedAt: capture?.startedAt ?? retrievedAt, + completedAt: capture?.completedAt ?? retrievedAt, + steps: [ + { + phase: 'observe', + label: screenshotStorageId + ? `Captured webpage evidence for ${source.title}` + : `Preserved search-provider snapshot for ${source.title}`, + status: screenshotStorageId && screenshotViewport ? 'ok' : 'warning', + ...(screenshotStorageId && !screenshotViewport + ? { + detail: + 'Stored the exact screenshot bytes, but the encoded image dimensions could not be verified. No region geometry was recorded.', + } + : !screenshotStorageId + ? { + detail: capture?.error + ? `${capture.error} Stored an exact title, URL, and excerpt snapshot instead; it is not a webpage screenshot.` + : 'Stored the exact search-provider title, URL, and excerpt as a PDF; it is not a webpage screenshot.', + } + : {}), + ...(screenshotStorageId + ? { + screenshotStorageId, + ...(screenshotBox ? { box: screenshotBox } : {}), + ...(screenshotViewport ? { viewport: screenshotViewport } : {}), + } + : {}), + ...(pdfStorageId + ? { pdfStorageId, box: snapshotPdf.box, viewport: snapshotPdf.viewport } + : {}), + regionScope: 'source', + quote: source.snippet.slice(0, 1000), + contentDigest, + startedAt: capture?.startedAt ?? retrievedAt, + completedAt: capture?.completedAt ?? retrievedAt, + }, + ], + }), + }); + } +} + +function normalizedStoredNodeSlideWebSourceUrl(value: string): string | undefined { + try { + const parsed = new URL(value); + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return undefined; + return parsed.toString().slice(0, 900); + } catch { + return undefined; + } +} + function extractPlan( value: unknown, fallback: ReturnType, diff --git a/convex/nodeslideAuthoringQuality.ts b/convex/nodeslideAuthoringQuality.ts new file mode 100644 index 0000000..8f2567b --- /dev/null +++ b/convex/nodeslideAuthoringQuality.ts @@ -0,0 +1,70 @@ +import { v } from 'convex/values'; +import type { NodeSlidePresentationQualityReceipt } from '../shared/nodeslideAuthoringQuality'; +import { + evaluateNodeSlidePresentationQuality, + verifyNodeSlidePresentationQualityReceipt, +} from '../shared/nodeslideAuthoringQuality'; +import type { NodeSlideJourneyProof } from '../shared/nodeslideJourneyProof'; +import { query } from './_generated/server'; +import { requireOwnerAccess } from './lib/nodeslideAccess'; +import { loadNodeSlideSnapshot } from './lib/nodeslideData'; + +const RECEIPT_JSON_LIMIT = 200_000; + +/** + * Owner-gated, read-only release preflight. Artifact proof is optional input but + * remains a blocker under the default policy, so callers cannot silently claim + * release readiness from content checks alone. + */ +export const evaluateLatest = query({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + journeyProofJson: v.optional(v.string()), + referenceReceiptJson: v.optional(v.string()), + }, + handler: async (ctx, args) => { + await requireOwnerAccess(ctx, args.deckId, args.ownerAccessKey); + const snapshot = await loadNodeSlideSnapshot(ctx, args.deckId); + if (!snapshot || snapshot.deck.id !== args.deckId) throw new Error('NodeSlide deck not found.'); + const journeyProof = parseBoundedJson( + args.journeyProofJson, + 'journey proof', + ); + const referenceReceipt = parseBoundedJson( + args.referenceReceiptJson, + 'reference receipt', + ); + if (referenceReceipt && !verifyNodeSlidePresentationQualityReceipt(referenceReceipt)) { + throw new Error('NodeSlide reference quality receipt is invalid.'); + } + const receipt = evaluateNodeSlidePresentationQuality(snapshot, { + ...(journeyProof ? { journeyProof } : {}), + ...(referenceReceipt ? { referenceReceipt } : {}), + }); + return { + receipt, + releaseReady: receipt.status !== 'fail', + contract: { + acceptsWithoutReview: false, + requiresJourneyProof: true, + requiresEditableExport: true, + requiresExactSingleVersionAdvance: true, + }, + }; + }, +}); + +function parseBoundedJson(value: string | undefined, label: string): T | undefined { + if (value === undefined) return undefined; + if (value.length < 2 || value.length > RECEIPT_JSON_LIMIT) { + throw new Error(`NodeSlide ${label} JSON is outside the allowed size.`); + } + try { + const parsed: unknown = JSON.parse(value); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) throw new Error('shape'); + return parsed as T; + } catch { + throw new Error(`NodeSlide ${label} JSON is invalid.`); + } +} diff --git a/convex/nodeslideBudgets.ts b/convex/nodeslideBudgets.ts index eaa7d57..3529cea 100644 --- a/convex/nodeslideBudgets.ts +++ b/convex/nodeslideBudgets.ts @@ -103,7 +103,7 @@ export const create = internalMutation({ deltas: zeroCostDeltas, previousEventDigest: undefined, }); - const budgetId = await ctx.db.insert('nodeslide_run_budgets', { + await ctx.db.insert('nodeslide_run_budgets', { id: args.budgetId, version: NODESLIDE_BUDGET_LEDGER_VERSION, status: transition.state.status, @@ -183,7 +183,7 @@ export const reserve = internalMutation({ cost: nextCost, }); const now = Date.now(); - const callId = await ctx.db.insert('nodeslide_billable_calls', { + await ctx.db.insert('nodeslide_billable_calls', { budgetId: args.budgetId, callId: args.callId, version: NODESLIDE_BILLABLE_CALL_VERSION, diff --git a/convex/nodeslideDeckCi.test.ts b/convex/nodeslideDeckCi.test.ts new file mode 100644 index 0000000..9e7d2ca --- /dev/null +++ b/convex/nodeslideDeckCi.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from 'vitest'; +import type { DeckSnapshot, SlideElement } from '../shared/nodeslide'; +import type { QueryCtx } from './_generated/server'; +import { nodeSlideCandidateDigest } from './lib/nodeslideCandidate'; +import { loadNodeSlideSnapshot } from './lib/nodeslideData'; +import type { NodeSlideDeckCiResult } from './lib/nodeslideDeckCi'; +import { evaluateNodeSlideDeckCi } from './lib/nodeslideDeckCi'; +import { buildGoldenNodeSlide } from './lib/nodeslideSeed'; +import { + NODESLIDE_DECK_CI_CHANGED_SOURCE_LIMIT, + evaluateCurrent, + evaluateCurrentInternal, + evaluateLatest, +} from './nodeslideDeckCi'; + +const OWNER_ACCESS_KEY = 'a'.repeat(43); +const SECOND_OWNER_ACCESS_KEY = 'b'.repeat(43); + +type StoredRow = Record & { + _id: string; + _creationTime: number; +}; + +type Filter = { field: string; value: unknown }; + +class MemoryIndex { + readonly filters: Filter[] = []; + + eq(field: string, value: unknown): this { + this.filters.push({ field, value }); + return this; + } +} + +class MemoryQuery { + private filters: readonly Filter[] = []; + + constructor( + private readonly database: MemoryDatabase, + private readonly tableName: string, + ) {} + + withIndex(_indexName: string, configure: (index: MemoryIndex) => unknown): this { + const index = new MemoryIndex(); + configure(index); + this.filters = index.filters; + return this; + } + + async collect(): Promise { + return this.database + .rows(this.tableName) + .filter((row) => this.filters.every((filter) => row[filter.field] === filter.value)); + } + + async first(): Promise { + return (await this.collect())[0] ?? null; + } +} + +class MemoryDatabase { + private readonly tables = new Map(); + private sequence = 0; + + query(tableName: string): MemoryQuery { + return new MemoryQuery(this, tableName); + } + + seed(tableName: string, value: object): void { + this.sequence += 1; + const rows = this.tables.get(tableName) ?? []; + rows.push({ + ...structuredClone(value), + _id: `${tableName}:${this.sequence}`, + _creationTime: this.sequence, + }); + this.tables.set(tableName, rows); + } + + rows(tableName: string): StoredRow[] { + return [...(this.tables.get(tableName) ?? [])]; + } + + totalRows(): number { + return [...this.tables.values()].reduce((total, rows) => total + rows.length, 0); + } +} + +type DeckCiArgs = { + deckId: string; + ownerAccessKey: string; + deckVersion: number; + snapshotDigest: string; + changedSourceIds?: string[]; +}; + +type QueryHandler = (ctx: QueryCtx, args: DeckCiArgs) => Promise; + +function registeredHandler(value: unknown): QueryHandler { + const handler = (value as { _handler?: unknown })._handler; + if (typeof handler !== 'function') throw new Error('Registered Convex handler is unavailable.'); + return handler as QueryHandler; +} + +const evaluateCurrentHandler = registeredHandler(evaluateCurrent); +const evaluateCurrentInternalHandler = registeredHandler(evaluateCurrentInternal); +const evaluateLatestHandler = registeredHandler(evaluateLatest) as unknown as ( + ctx: QueryCtx, + args: Pick, +) => Promise; + +describe('NodeSlide Deck CI Convex query', () => { + it('computes the display result from the owner-authorized latest snapshot server-side', async () => { + const workspace = await createWorkspace('latest-display'); + const result = await evaluateLatestHandler(workspace.context, { + deckId: workspace.snapshot.deck.id, + ownerAccessKey: OWNER_ACCESS_KEY, + }); + + expect(result).toMatchObject({ + deckId: workspace.snapshot.deck.id, + deckVersion: workspace.snapshot.deck.version, + snapshotDigest: nodeSlideCandidateDigest(workspace.snapshot), + }); + await expect( + evaluateLatestHandler(workspace.context, { + deckId: workspace.snapshot.deck.id, + ownerAccessKey: SECOND_OWNER_ACCESS_KEY, + }), + ).rejects.toThrow(/owner access denied/i); + }); + + it('requires the exact owner capability before evaluating', async () => { + const workspace = await createWorkspace('owner-gate'); + const binding = bindingFor(workspace.snapshot); + + await expect( + evaluateCurrentHandler(workspace.context, { + ...binding, + ownerAccessKey: SECOND_OWNER_ACCESS_KEY, + }), + ).rejects.toThrow(/owner access denied/i); + await expect( + evaluateCurrentInternalHandler(workspace.context, { + ...binding, + ownerAccessKey: 'not-an-owner-key', + }), + ).rejects.toThrow(/owner access denied/i); + }); + + it('accepts only the exact authoritative current deck version and digest', async () => { + const workspace = await createWorkspace('exact-binding'); + const binding = bindingFor(workspace.snapshot); + + await expect( + evaluateCurrentHandler(workspace.context, { + ...binding, + ownerAccessKey: OWNER_ACCESS_KEY, + }), + ).resolves.toMatchObject({ + deckId: workspace.snapshot.deck.id, + deckVersion: workspace.snapshot.deck.version, + snapshotDigest: binding.snapshotDigest, + }); + await expect( + evaluateCurrentHandler(workspace.context, { + ...binding, + ownerAccessKey: OWNER_ACCESS_KEY, + deckVersion: workspace.snapshot.deck.version - 1, + }), + ).rejects.toThrow(/snapshot binding denied/i); + await expect( + evaluateCurrentHandler(workspace.context, { + ...binding, + ownerAccessKey: OWNER_ACCESS_KEY, + snapshotDigest: `sha256:${'0'.repeat(64)}`, + }), + ).rejects.toThrow(/snapshot binding denied/i); + }); + + it('reports bounded changed-source impact from the authoritative snapshot', async () => { + const workspace = await createWorkspace('source-impact'); + const changedSourceId = requiredBoundSourceId(workspace.snapshot); + const missingSourceId = 'source_missing_from_authoritative_snapshot'; + const changedSourceIds = [missingSourceId, changedSourceId, changedSourceId]; + + const result = await evaluateCurrentHandler(workspace.context, { + ...bindingFor(workspace.snapshot), + ownerAccessKey: OWNER_ACCESS_KEY, + changedSourceIds, + }); + const boundElements = workspace.snapshot.elements.filter((element) => + sourceIdsForElement(element).includes(changedSourceId), + ); + const boundSlideIds = workspace.snapshot.deck.slideOrder.filter((slideId) => + boundElements.some((element) => element.slideId === slideId), + ); + + expect(result.changedSourceImpact).toMatchObject({ + changedSourceIds: [missingSourceId, changedSourceId].sort(), + boundSourceIds: [changedSourceId], + missingSourceIds: [missingSourceId], + slideIds: boundSlideIds, + elementIds: boundElements.map((element) => element.id), + }); + await expect( + evaluateCurrentHandler(workspace.context, { + ...bindingFor(workspace.snapshot), + ownerAccessKey: OWNER_ACCESS_KEY, + changedSourceIds: Array.from( + { length: NODESLIDE_DECK_CI_CHANGED_SOURCE_LIMIT + 1 }, + (_, index) => `source-${index}`, + ), + }), + ).rejects.toThrow(/changedSourceIds.*at most/i); + }); + + it('is deterministic across public, internal, and direct pure evaluation without writes', async () => { + const workspace = await createWorkspace('deterministic'); + const changedSourceIds = [requiredBoundSourceId(workspace.snapshot)]; + const args = { + ...bindingFor(workspace.snapshot), + ownerAccessKey: OWNER_ACCESS_KEY, + changedSourceIds, + }; + const rowsBefore = workspace.database.totalRows(); + const expected = evaluateNodeSlideDeckCi(structuredClone(workspace.snapshot), { + changedSourceIds, + }); + + const first = await evaluateCurrentHandler(workspace.context, args); + const second = await evaluateCurrentHandler(workspace.context, structuredClone(args)); + const internal = await evaluateCurrentInternalHandler(workspace.context, args); + + expect(first).toEqual(expected); + expect(second).toEqual(first); + expect(internal).toEqual(first); + expect(workspace.database.totalRows()).toBe(rowsBefore); + expect(workspace.database.rows('nodeslide_validations')).toHaveLength(0); + }); + + it('denies a valid owner capability from a different deck', async () => { + const database = new MemoryDatabase(); + const first = snapshotFor('cross-deck-first'); + const second = snapshotFor('cross-deck-second'); + seedSnapshot(database, first, OWNER_ACCESS_KEY); + seedSnapshot(database, second, SECOND_OWNER_ACCESS_KEY); + const context = { db: database } as unknown as QueryCtx; + const authoritativeSecond = await loadNodeSlideSnapshot(context, second.deck.id); + if (!authoritativeSecond) throw new Error('Expected the second authoritative snapshot.'); + + await expect( + evaluateCurrentHandler(context, { + ...bindingFor(authoritativeSecond), + ownerAccessKey: OWNER_ACCESS_KEY, + }), + ).rejects.toThrow(/owner access denied/i); + }); +}); + +async function createWorkspace(sessionId: string): Promise<{ + database: MemoryDatabase; + context: QueryCtx; + snapshot: DeckSnapshot; +}> { + const database = new MemoryDatabase(); + const seed = snapshotFor(sessionId); + seedSnapshot(database, seed, OWNER_ACCESS_KEY); + const context = { db: database } as unknown as QueryCtx; + const snapshot = await loadNodeSlideSnapshot(context, seed.deck.id); + if (!snapshot) throw new Error('Expected the authoritative snapshot.'); + return { database, context, snapshot }; +} + +function snapshotFor(sessionId: string): DeckSnapshot { + const snapshot = buildGoldenNodeSlide(sessionId, 10_000).snapshot; + snapshot.deck.version = 7; + return snapshot; +} + +function bindingFor(snapshot: DeckSnapshot): Omit { + return { + deckId: snapshot.deck.id, + deckVersion: snapshot.deck.version, + snapshotDigest: nodeSlideCandidateDigest(snapshot), + }; +} + +function seedSnapshot( + database: MemoryDatabase, + snapshot: DeckSnapshot, + ownerAccessKey: string, +): void { + const now = snapshot.deck.updatedAt; + database.seed('nodeslide_decks', { + ...snapshot.deck, + projectRowId: `projects:${snapshot.deck.projectId}`, + clientSessionId: snapshot.deck.id, + ownerAccessKey, + plan: [], + spec: {}, + }); + for (const slide of snapshot.slides) { + database.seed('nodeslide_slides', { ...slide, createdAt: now, updatedAt: now }); + } + for (const element of snapshot.elements) { + database.seed('nodeslide_elements', { + ...element, + deckId: snapshot.deck.id, + createdAt: now, + updatedAt: now, + }); + } + for (const source of snapshot.sources) database.seed('nodeslide_sources', source); +} + +function requiredBoundSourceId(snapshot: DeckSnapshot): string { + const source = snapshot.sources.find((candidate) => + snapshot.elements.some((element) => sourceIdsForElement(element).includes(candidate.id)), + ); + if (!source) throw new Error('Expected a source-bound element in the golden snapshot.'); + return source.id; +} + +function sourceIdsForElement(element: SlideElement): string[] { + return [ + ...element.sourceIds, + ...(element.chart?.sourceId ? [element.chart.sourceId] : []), + ...(element.math?.sourceId ? [element.math.sourceId] : []), + ...(element.image?.sourceId ? [element.image.sourceId] : []), + ]; +} diff --git a/convex/nodeslideDeckCi.ts b/convex/nodeslideDeckCi.ts new file mode 100644 index 0000000..a9e776e --- /dev/null +++ b/convex/nodeslideDeckCi.ts @@ -0,0 +1,129 @@ +import { v } from 'convex/values'; +import { NODESLIDE_ELEMENT_SOURCE_LIMIT } from '../shared/nodeslide'; +import type { QueryCtx } from './_generated/server'; +import { internalQuery, query } from './_generated/server'; +import { requireOwnerAccess } from './lib/nodeslideAccess'; +import { nodeSlideCandidateDigest } from './lib/nodeslideCandidate'; +import { loadNodeSlideSnapshot } from './lib/nodeslideData'; +import { type NodeSlideDeckCiResult, evaluateNodeSlideDeckCi } from './lib/nodeslideDeckCi'; + +export const NODESLIDE_DECK_CI_CHANGED_SOURCE_LIMIT = NODESLIDE_ELEMENT_SOURCE_LIMIT; + +const NODESLIDE_DECK_CI_ID_LIMIT = 256; +const NODESLIDE_DECK_CI_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +const SNAPSHOT_BINDING_DENIED = 'NodeSlide Deck CI snapshot binding denied.'; + +const args = { + deckId: v.string(), + ownerAccessKey: v.string(), + deckVersion: v.number(), + snapshotDigest: v.string(), + changedSourceIds: v.optional(v.array(v.string())), +}; + +const latestArgs = { + deckId: v.string(), + ownerAccessKey: v.string(), + changedSourceIds: v.optional(v.array(v.string())), +}; + +interface NodeSlideDeckCiQueryArgs { + deckId: string; + ownerAccessKey: string; + deckVersion: number; + snapshotDigest: string; + changedSourceIds?: string[]; +} + +interface NodeSlideDeckCiLatestQueryArgs { + deckId: string; + ownerAccessKey: string; + changedSourceIds?: string[]; +} + +/** Evaluates Deck CI only for the owner's exact authoritative current snapshot. */ +export const evaluateCurrent = query({ + args, + handler: evaluateOwnerBoundCurrentSnapshot, +}); + +/** Read-only display form. The server binds the result to the authoritative latest snapshot. */ +export const evaluateLatest = query({ + args: latestArgs, + handler: evaluateOwnerLatestSnapshot, +}); + +/** + * Internal form for durable jobs. It intentionally retains the owner capability gate and exact + * snapshot binding so a stale or cross-deck job cannot evaluate a different current snapshot. + */ +export const evaluateCurrentInternal = internalQuery({ + args, + handler: evaluateOwnerBoundCurrentSnapshot, +}); + +async function evaluateOwnerLatestSnapshot( + ctx: QueryCtx, + input: NodeSlideDeckCiLatestQueryArgs, +): Promise { + await requireOwnerAccess(ctx, input.deckId, input.ownerAccessKey); + const changedSourceIds = requireBoundedChangedSourceIds(input.changedSourceIds); + const snapshot = await loadNodeSlideSnapshot(ctx, input.deckId); + if (!snapshot || snapshot.deck.id !== input.deckId) { + throw new Error('NodeSlide deck not found.'); + } + return evaluateNodeSlideDeckCi(snapshot, { changedSourceIds }); +} + +async function evaluateOwnerBoundCurrentSnapshot( + ctx: QueryCtx, + input: NodeSlideDeckCiQueryArgs, +): Promise { + await requireOwnerAccess(ctx, input.deckId, input.ownerAccessKey); + const changedSourceIds = requireBoundedChangedSourceIds(input.changedSourceIds); + if ( + !Number.isSafeInteger(input.deckVersion) || + input.deckVersion < 1 || + !NODESLIDE_DECK_CI_DIGEST_PATTERN.test(input.snapshotDigest) + ) { + throw new Error(SNAPSHOT_BINDING_DENIED); + } + + const snapshot = await loadNodeSlideSnapshot(ctx, input.deckId); + if ( + !snapshot || + snapshot.deck.id !== input.deckId || + snapshot.deck.version !== input.deckVersion || + nodeSlideCandidateDigest(snapshot) !== input.snapshotDigest + ) { + throw new Error(SNAPSHOT_BINDING_DENIED); + } + + const result = evaluateNodeSlideDeckCi(snapshot, { changedSourceIds }); + if ( + result.deckId !== input.deckId || + result.deckVersion !== input.deckVersion || + result.snapshotDigest !== input.snapshotDigest + ) { + throw new Error(SNAPSHOT_BINDING_DENIED); + } + return result; +} + +function requireBoundedChangedSourceIds(value: string[] | undefined): string[] { + if (!value) return []; + if ( + value.length > NODESLIDE_DECK_CI_CHANGED_SOURCE_LIMIT || + value.some( + (sourceId) => + sourceId.length < 1 || + sourceId.length > NODESLIDE_DECK_CI_ID_LIMIT || + sourceId.trim() !== sourceId, + ) + ) { + throw new Error( + `NodeSlide Deck CI changedSourceIds must contain at most ${NODESLIDE_DECK_CI_CHANGED_SOURCE_LIMIT} bounded source IDs.`, + ); + } + return [...value]; +} diff --git a/convex/nodeslideDelegation.test.ts b/convex/nodeslideDelegation.test.ts index 91c76e1..1129a7e 100644 --- a/convex/nodeslideDelegation.test.ts +++ b/convex/nodeslideDelegation.test.ts @@ -652,6 +652,8 @@ describe('NodeSlide delegated acceptance', () => { replayed: false, }); expect(accepted.workspace).toBeNull(); + const acceptedVersionCount = harness.database.rows('nodeslide_versions').length; + const acceptedSnapshot = await requiredSnapshot(harness.context, harness.snapshot.deck.id); harness.database.resetQueryCalls(); const replay = await delegatedAcceptHandler(harness.context, args); expect(replay.patch).toEqual(accepted.patch); @@ -664,6 +666,10 @@ describe('NodeSlide delegated acceptance', () => { ]); expect(harness.database.rows('nodeslide_delegation_uses')).toHaveLength(1); expect(grantRow(harness.database, issued.grant.id)['useCount']).toBe(1); + expect(harness.database.rows('nodeslide_versions')).toHaveLength(acceptedVersionCount); + expect(await requiredSnapshot(harness.context, harness.snapshot.deck.id)).toEqual( + acceptedSnapshot, + ); setNow(START_TIME + 2); const secondProposal = await proposeTextEdit(harness, 'exhausted-use', 'Second delegated use'); @@ -688,6 +694,67 @@ describe('NodeSlide delegated acceptance', () => { expect(harness.database.queryCalls).toEqual(['nodeslide_delegation_grants']); }); + it('persists a Deck CI failure for review without mutation or grant consumption', async () => { + const harness = workspaceHarness('deck-ci-review'); + const issued = await issueGrantHandler(harness.context, grantArgs(harness.snapshot.deck.id)); + setNow(START_TIME + 1); + const proposal = await proposeTextEdit( + harness, + 'deck-ci-review-proposal', + 'A bounded edit while evidence refresh is still in progress.', + ); + const storedProposal = patchRow(harness.database, proposal.patch.id); + const candidateValidation = storedProposal['candidateValidation']; + if (!candidateValidation || typeof candidateValidation !== 'object') { + throw new Error('Deck CI candidate receipt fixture is missing.'); + } + storedProposal['candidateValidation'] = { + ...(candidateValidation as NonNullable), + publishOk: false, + cleanOk: false, + issues: [ + { + id: 'deck-ci-review-warning', + severity: 'warning', + code: 'overflow', + message: 'Rendered copy requires review before publication.', + }, + ], + }; + const before = await requiredSnapshot(harness.context, harness.snapshot.deck.id); + seedAgentRun(harness.database, harness.snapshot.deck.id, proposal.patch); + + const receipt = await delegatedAcceptHandler( + harness.context, + acceptArgs(issued, proposal.patch), + ); + + expect(receipt.patch.status).toBe('ready'); + expect(receipt.autoCommit).toMatchObject({ + outcome: 'awaiting_review', + reason: 'deck_ci_pass_required', + }); + expect(receipt.autoCommit?.deckCiStatus).not.toBe('pass'); + expect(receipt.autoCommit?.deckCiDigest).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(receipt.staleReasons?.[0]).toMatch(/did not mutate.*deck ci/i); + expect(await requiredSnapshot(harness.context, harness.snapshot.deck.id)).toEqual(before); + expect(grantRow(harness.database, issued.grant.id)['useCount']).toBe(0); + expect(harness.database.rows('nodeslide_delegation_uses')).toEqual([]); + expect( + harness.database.only('nodeslide_agent_runs', 'id', `${proposal.patch.id}-run`), + ).toMatchObject({ + status: 'awaiting_review', + checkpoint: `deck-ci:${receipt.autoCommit?.deckCiDigest}`, + error: expect.stringMatching(/deck ci returned/i), + }); + expect(harness.database.only('nodeslide_traces', 'patchId', proposal.patch.id)).toMatchObject({ + status: 'awaiting_review', + summary: expect.stringMatching(/deck ci returned/i), + }); + + expect(patchRow(harness.database, proposal.patch.id)['status']).toBe('ready'); + }); + it('accounts 24 sequential uses exactly and denies use 25 before proposal lookup', async () => { const harness = workspaceHarness('sequential-use-accounting'); const issued = await issueGrantHandler(harness.context, { diff --git a/convex/nodeslideDelegation.ts b/convex/nodeslideDelegation.ts index 874f070..a0d0069 100644 --- a/convex/nodeslideDelegation.ts +++ b/convex/nodeslideDelegation.ts @@ -12,6 +12,8 @@ import { type NodeSlideDelegationGrant, type NodeSlideDelegationIssueReceipt, type NodeSlideDelegationPolicy, + evaluateNodeSlideDelegationAutoCommit, + nodeSlideDelegationCandidateViolations, nodeSlideDelegationGrantStatus, nodeSlideDelegationPolicyDigestInput, nodeSlideDelegationProposalViolations, @@ -20,9 +22,23 @@ import type { Doc } from './_generated/dataModel'; import type { MutationCtx } from './_generated/server'; import { mutation, query } from './_generated/server'; import { requireOwnerAccess } from './lib/nodeslideAccess'; -import { validationFromCandidateReceipt } from './lib/nodeslideCandidate'; -import { findDeckRow, findPatchRow, patchFromRow } from './lib/nodeslideData'; +import { + materializeNodeSlideCandidate, + validationFromCandidateReceipt, +} from './lib/nodeslideCandidate'; +import { + findDeckRow, + findPatchRow, + loadNodeSlideSnapshot, + patchFromRow, +} from './lib/nodeslideData'; +import { + type NodeSlideDeckCiResult, + evaluateNodeSlideDeckCi, + nodeSlideDeckCiAllowsAutoCommit, +} from './lib/nodeslideDeckCi'; import { nodeslideContentDigest, nodeslideEventId, nodeslideStableId } from './lib/nodeslideIds'; +import { evaluateNodeSlideCas } from './lib/nodeslidePatches'; import { commitDelegatedNodeSlideProposal } from './nodeslide'; const NODESLIDE_DELEGATION_TOKEN_BYTES = 32; @@ -139,6 +155,10 @@ export const acceptValidatedProposalWithGrant = mutation({ throw new Error('Delegated proposal is unavailable.'); } assertProposalBinding(grant, proposal, args.expectedCandidateDigest); + const candidateValidation = proposal.candidateValidation; + if (!candidateValidation) { + throw new Error('Validated proposal candidate receipt is unavailable.'); + } if (proposal.createdAt < grant.createdAt) { throw new Error('Delegated acceptance rejects proposals created before the grant.'); } @@ -164,6 +184,59 @@ export const acceptValidatedProposalWithGrant = mutation({ const deckRow = await findDeckRow(ctx, args.deckId); if (!deckRow) throw new Error('Delegated proposal deck is unavailable.'); + const snapshot = await loadNodeSlideSnapshot(ctx, args.deckId); + if (!snapshot) throw new Error('Delegated proposal deck is unavailable.'); + const cas = evaluateNodeSlideCas(snapshot, proposal); + if (!cas.canCommit) { + const stale = await commitDelegatedNodeSlideProposal(ctx, proposal, { + deckRow, + grantId: grant.id, + clientKind: grant.clientKind, + policyDigest: grant.policyDigest, + }); + return { + ...stale, + workspace: null, + rebased: false, + delegation: delegationUseReceipt(grant, false), + }; + } + const candidate = materializeNodeSlideCandidate(snapshot, proposal, now); + const candidateViolations = nodeSlideDelegationCandidateViolations({ + baseline: snapshot, + candidate, + operations: proposal.operations, + }); + if (candidateViolations.length > 0) throw new Error(candidateViolations.join(' ')); + const deckCi = evaluateNodeSlideDeckCi(candidate, { + referenceTime: now, + validation: validationFromCandidateReceipt(candidateValidation), + }); + const autoCommitDecision = evaluateNodeSlideDelegationAutoCommit({ + grant, + proposal, + evaluatedAt: now, + candidateValidationPassed: candidateValidation.ok, + deckCiPassed: nodeSlideDeckCiAllowsAutoCommit(deckCi), + }); + if (autoCommitDecision.outcome !== 'commit') { + await persistAutoCommitReviewReceipt(ctx, proposal, deckCi, now); + return { + patch: patchFromRow({ ...proposal, status: 'ready', updatedAt: now }), + workspace: null, + validation: validationFromCandidateReceipt(candidateValidation), + rebased: false, + staleReasons: [deckCiReviewMessage(deckCi)], + delegation: delegationUseReceipt(grant, false), + autoCommit: { + outcome: autoCommitDecision.outcome, + reason: autoCommitDecision.reason, + deckCiStatus: deckCi.status, + deckCiDigest: deckCi.digest, + deckCiBlockerCount: deckCi.blockerCount, + }, + }; + } const committed = await commitDelegatedNodeSlideProposal(ctx, proposal, { deckRow, grantId: grant.id, @@ -360,6 +433,57 @@ async function replayReceipt( }; } +async function persistAutoCommitReviewReceipt( + ctx: MutationCtx, + proposal: Doc<'nodeslide_patches'>, + deckCi: NodeSlideDeckCiResult, + now: number, +): Promise { + const message = deckCiReviewMessage(deckCi); + await ctx.db.patch(proposal._id, { status: 'ready', updatedAt: now }); + + const run = proposal.jobId + ? await ctx.db + .query('nodeslide_agent_runs') + .withIndex('by_stable_id', (index) => index.eq('id', proposal.jobId as string)) + .first() + : (( + await ctx.db + .query('nodeslide_agent_runs') + .withIndex('by_deck_created', (index) => index.eq('deckId', proposal.deckId)) + .order('desc') + .take(NODESLIDE_DELEGATION_LIST_LIMIT) + ).find((candidate) => candidate.patchId === proposal.id) ?? null); + if (run && run.status !== 'completed' && run.status !== 'cancelled') { + await ctx.db.patch(run._id, { + status: 'awaiting_review', + checkpoint: `deck-ci:${deckCi.digest}`, + error: message, + updatedAt: now, + completedAt: undefined, + }); + } + + const trace = await ctx.db + .query('nodeslide_traces') + .withIndex('by_patch', (index) => index.eq('patchId', proposal.id)) + .first(); + if (trace && trace.status !== 'completed' && trace.status !== 'cancelled') { + await ctx.db.patch(trace._id, { + status: 'awaiting_review', + summary: message, + candidateDigest: proposal.candidateDigest, + completedAt: undefined, + }); + } +} + +function deckCiReviewMessage(deckCi: NodeSlideDeckCiResult): string { + const blocking = deckCi.checks.find((check) => check.blocker) ?? deckCi.checks[0]; + const detail = blocking ? ` ${blocking.message}` : ''; + return `Turbo did not mutate the deck because deterministic Deck CI returned ${deckCi.status}.${detail}`; +} + function delegationUseReceipt( grant: Pick, 'id' | 'useCount' | 'maxUses'>, replayed: boolean, diff --git a/convex/nodeslideEvidenceBinding.test.ts b/convex/nodeslideEvidenceBinding.test.ts new file mode 100644 index 0000000..a2e5e66 --- /dev/null +++ b/convex/nodeslideEvidenceBinding.test.ts @@ -0,0 +1,102 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const mutationSource = readFileSync(new URL('./nodeslide.ts', import.meta.url), 'utf8'); +const schemaSource = readFileSync(new URL('./schema.ts', import.meta.url), 'utf8'); +const exportSource = readFileSync(new URL('./lib/nodeslideDataExport.ts', import.meta.url), 'utf8'); +const deletionSource = readFileSync( + new URL('./lib/nodeslideDeckDeletion.ts', import.meta.url), + 'utf8', +); + +describe('NodeSlide immutable evidence binding integration', () => { + it('persists owner/deck indexed append-only source revisions and claim receipts', () => { + expect(schemaSource).toContain('nodeslide_source_revisions: defineTable'); + expect(schemaSource).toContain(".index('by_owner_created', ['ownerDigest', 'createdAt'])"); + expect(schemaSource).toContain( + ".index('by_source_content_digest', ['sourceId', 'contentDigest'])", + ); + expect(schemaSource).toContain('nodeslide_claim_evidence_receipts: defineTable'); + expect(schemaSource).toContain(".index('by_patch_created', ['patchId', 'createdAt'])"); + expect(schemaSource).toContain( + ".index('by_source_revision_created', ['sourceRevisionId', 'createdAt'])", + ); + }); + + it('binds every new source/capture to an immutable revision without overwriting revisions', () => { + const helperStart = mutationSource.indexOf('async function ensureNodeSlideSourceRevision'); + const helperEnd = mutationSource.indexOf( + 'async function persistNodeSlideClaimEvidenceReceipts', + ); + const helper = mutationSource.slice(helperStart, helperEnd); + expect(helper).toContain(".query('nodeslide_source_revisions')"); + expect(helper).toContain(".withIndex('by_source_content_digest'"); + expect(helper).toContain(".withIndex('by_source_created'"); + expect(helper).toContain("ctx.db.insert('nodeslide_source_revisions'"); + expect(helper).not.toContain('ctx.db.patch('); + + const attachmentStart = mutationSource.indexOf('export const attachDataSource'); + const captureStart = mutationSource.indexOf('export const recordEvidenceCaptureInternal'); + const webStart = mutationSource.indexOf('export const attachWebSourcesInternal'); + expect(mutationSource.slice(attachmentStart, captureStart)).toContain( + 'await ensureNodeSlideSourceRevision', + ); + expect(mutationSource.slice(captureStart, webStart)).toContain( + 'sourceRevisionId: sourceRevision.id', + ); + expect(mutationSource.slice(webStart)).toContain('await ensureNodeSlideSourceRevision'); + }); + + it('creates digest-bound claim receipts at proposal and acceptance only from exact geometry', () => { + const helperStart = mutationSource.indexOf( + 'async function persistNodeSlideClaimEvidenceReceipts', + ); + const helperEnd = mutationSource.indexOf( + '// biome-ignore lint/suspicious/noExplicitAny', + helperStart, + ); + const helper = mutationSource.slice(helperStart, helperEnd); + expect(helper).toContain('buildNodeSlideClaimEvidenceReceipt'); + expect(helper).toContain('!candidate.box'); + expect(helper).toContain("candidate.regionScope !== 'claim'"); + expect(helper).toContain("candidate.attachmentKind === 'screenshot'"); + expect(helper).toContain('Number.isInteger(candidate.box.pageCount)'); + expect(helper).not.toMatch(/pageCount:\s*\d+/u); + expect(helper).toContain("ctx.db.insert('nodeslide_claim_evidence_receipts'"); + + const proposalStart = mutationSource.indexOf('export const proposeAgentPatchInternal'); + const commitStart = mutationSource.indexOf('async function commitPatch'); + expect(mutationSource.slice(proposalStart, commitStart)).toContain( + 'await persistNodeSlideClaimEvidenceReceipts', + ); + expect(mutationSource.slice(commitStart)).toContain( + 'await persistNodeSlideClaimEvidenceReceipts', + ); + }); + + it('keeps signed storage URLs out of summaries and resolves only selected capture detail', () => { + const summaryStart = mutationSource.indexOf('export const listEvidenceCaptureSummaries'); + const detailStart = mutationSource.indexOf('export const getEvidenceCaptureDetail'); + const detailEnd = mutationSource.indexOf('export const cancelAgentRun', detailStart); + const summaryQuery = mutationSource.slice(summaryStart, detailStart); + const detailQuery = mutationSource.slice(detailStart, detailEnd); + + expect(summaryQuery).not.toContain('ctx.storage.getUrl'); + expect(summaryQuery).not.toContain('screenshotStorageId'); + expect(summaryQuery).not.toContain('pdfStorageId'); + expect(detailQuery).toContain('ctx.storage.getUrl(screenshotStorageId)'); + expect(detailQuery).toContain('ctx.storage.getUrl(pdfStorageId)'); + expect(detailQuery).toContain("query.eq('id', args.captureId)"); + }); + + it('exports and erases both immutable custody collections', () => { + for (const table of ['nodeslide_source_revisions', 'nodeslide_claim_evidence_receipts']) { + expect(exportSource).toContain(`.query('${table}')`); + expect(deletionSource).toContain(`'${table}'`); + expect(deletionSource).toContain(`.query('${table}')`); + } + expect(exportSource).toContain('sourceRevisions: redactRows'); + expect(exportSource).toContain('claimReceipts: redactRows'); + expect(exportSource).toContain('claim evidence receipt custody binding is inconsistent'); + }); +}); diff --git a/convex/nodeslideJobControl.test.ts b/convex/nodeslideJobControl.test.ts new file mode 100644 index 0000000..dc89f27 --- /dev/null +++ b/convex/nodeslideJobControl.test.ts @@ -0,0 +1,53 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const source = readFileSync( + fileURLToPath(new URL('./nodeslideJobControl.ts', import.meta.url)), + 'utf8', +); +const jobsSource = readFileSync( + fileURLToPath(new URL('./nodeslideJobs.ts', import.meta.url)), + 'utf8', +); + +describe('NodeSlide owner-authorized durable job controls', () => { + it.each(['pause', 'resume', 'getFreshness'])( + 'gates %s by the job owner capability digest', + (operation) => { + const start = source.indexOf(`export const ${operation}`); + const nextExport = source.indexOf('\nexport const ', start + 1); + const body = source.slice(start, nextExport < 0 ? undefined : nextExport); + expect(start).toBeGreaterThanOrEqual(0); + expect(body).toContain('findAuthorizedJob(ctx, args.jobId, args.ownerAccessKey)'); + }, + ); + + it('resumes the same workflow action instead of starting a duplicate job', () => { + expect(source).toContain('workflow.restart(ctx, row.workflowId as WorkflowId'); + expect(source).toContain("'execute-create-deck'"); + expect(source).toContain("'execute-edit-proposal'"); + expect(source).not.toContain('workflow.start('); + }); + + it('keeps duplicate resume requests idempotent and preserves the existing receipt', () => { + expect(source).toContain( + "if (row.status !== 'paused') return publicNodeSlideJob(jobFromRow(row));", + ); + }); + + it('retains the existing owner-authorized terminal cancellation path', () => { + const start = jobsSource.indexOf('export const cancel = mutation'); + const end = jobsSource.indexOf('\nexport const retry = mutation', start); + const cancelBody = jobsSource.slice(start, end); + expect(cancelBody).toContain('findAuthorizedJob(ctx, args.jobId, args.ownerAccessKey)'); + expect(cancelBody).toContain('cancelNodeSlideJob(current, Date.now())'); + expect(cancelBody).toContain('workflow.cancel(ctx'); + }); + + it('does not route pause or resume through acceptance or patch mutation code', () => { + expect(source).not.toContain('acceptPatch'); + expect(source).not.toContain('applyPatch'); + expect(source).not.toContain('applyOps'); + }); +}); diff --git a/convex/nodeslideJobControl.ts b/convex/nodeslideJobControl.ts new file mode 100644 index 0000000..47d3725 --- /dev/null +++ b/convex/nodeslideJobControl.ts @@ -0,0 +1,215 @@ +import type { WorkflowId } from '@convex-dev/workflow'; +import { v } from 'convex/values'; +import type { NodeSlideDurableJobStatus } from '../shared/nodeslideDurableSession'; +import { internal } from './_generated/api'; +import type { Doc } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { internalMutation, mutation, query } from './_generated/server'; +import { isOwnerAccessKey } from './lib/nodeslideAccess'; +import { nodeslideStableId } from './lib/nodeslideIds'; +import { + type NodeSlideJobRecord, + classifyNodeSlideJobFreshness, + heartbeatNodeSlideJob, + nodeSlideJobOwnerDigest, + pauseNodeSlideJob, + publicNodeSlideJob, + resumeNodeSlideJob, +} from './lib/nodeslideJobState'; +import { workflow } from './workflows'; + +// Generated references form a deliberate job-control -> durable-session cycle. +// Runtime inputs remain validator checked at every public/internal boundary. +// biome-ignore lint/suspicious/noExplicitAny: generated Convex self-reference boundary +const sessionsInternal: any = (internal as any).nodeslideSessions; + +const SESSION_LEASE_MS = 3_900_000; + +type ReadCtx = Pick | Pick; +type DurableSessionProjection = { + sessionId: string; + requestBinding: { + schemaVersion: 'nodeslide.request-binding/v2'; + requestDigest: string; + capabilityDigest: string; + }; + stateVersion: number; + egressEpoch: number; + jobs: Array<{ jobId: string; status: NodeSlideDurableJobStatus; attempt: number }>; +}; + +/** Owner-authorized cooperative pause. In-flight work is fenced at its next durable boundary. */ +export const pause = mutation({ + args: { jobId: v.string(), ownerAccessKey: v.string() }, + handler: async (ctx, args) => { + const row = await findAuthorizedJob(ctx, args.jobId, args.ownerAccessKey); + if (!row) return null; + const current = jobFromRow(row); + const next = pauseNodeSlideJob(current, Date.now()); + if (next === current) return publicNodeSlideJob(current); + + await pauseDurableSession(ctx, current); + await patchJobControlState(ctx, row, next); + return publicNodeSlideJob(next); + }, +}); + +/** Owner-authorized resume that keeps the same job id, request binding, and attempt fence. */ +export const resume = mutation({ + args: { jobId: v.string(), ownerAccessKey: v.string() }, + handler: async (ctx, args) => { + const row = await findAuthorizedJob(ctx, args.jobId, args.ownerAccessKey); + if (!row) return null; + if (row.status !== 'paused') return publicNodeSlideJob(jobFromRow(row)); + if (!row.workflowId) throw new Error('NodeSlide job has no durable workflow to resume.'); + + const durable = await readDurableSession(ctx, row.id); + const durableJob = durable?.jobs.find((candidate) => candidate.jobId === row.id); + const resumeTo = + durableJob?.status === 'retrying' + ? 'retrying' + : durableJob?.status === 'paused' + ? 'running' + : 'queued'; + const next = resumeNodeSlideJob(jobFromRow(row), Date.now(), resumeTo); + + if (durable && durableJob?.status === 'paused') { + await resumeDurableSession(ctx, durable, durableJob); + } + await patchJobControlState(ctx, row, next); + await workflow.restart(ctx, row.workflowId as WorkflowId, { + from: row.kind === 'create_deck' ? 'execute-create-deck' : 'execute-edit-proposal', + startAsync: true, + }); + return publicNodeSlideJob(next); + }, +}); + +/** Owner-gated freshness projection. A stale heartbeat never changes the terminal outcome. */ +export const getFreshness = query({ + args: { jobId: v.string(), ownerAccessKey: v.string(), now: v.optional(v.number()) }, + handler: async (ctx, args) => { + const row = await findAuthorizedJob(ctx, args.jobId, args.ownerAccessKey); + return row ? classifyNodeSlideJobFreshness(jobFromRow(row), args.now ?? Date.now()) : null; + }, +}); + +/** + * Workflow-safe heartbeat and execution gate. A paused or terminal receipt + * returns shouldRun=false, allowing the workflow to stop without claiming a + * fabricated failure or completion. + */ +export const heartbeatInternal = internalMutation({ + args: { jobId: v.string() }, + handler: async (ctx, args) => { + const row = await findJob(ctx, args.jobId); + if (!row) throw new Error('NodeSlide job not found.'); + const current = jobFromRow(row); + const next = heartbeatNodeSlideJob(current, Date.now()); + if (next !== current) await patchJobControlState(ctx, row, next); + return { + status: next.status, + shouldRun: + next.status === 'queued' || next.status === 'running' || next.status === 'retrying', + ...classifyNodeSlideJobFreshness(next), + }; + }, +}); + +function jobFromRow(row: Doc<'nodeslide_agent_jobs'>): NodeSlideJobRecord { + const { _id: _rowId, _creationTime, ...job } = row; + return job; +} + +async function findJob(ctx: ReadCtx, jobId: string) { + return await ctx.db + .query('nodeslide_agent_jobs') + .withIndex('by_stable_id', (queryBuilder) => queryBuilder.eq('id', requiredJobId(jobId))) + .unique(); +} + +async function findAuthorizedJob(ctx: ReadCtx, jobId: string, ownerAccessKey: string) { + if (!isOwnerAccessKey(ownerAccessKey)) throw new Error('Invalid NodeSlide job owner capability.'); + const row = await findJob(ctx, jobId); + return row && row.ownerDigest === nodeSlideJobOwnerDigest(ownerAccessKey) ? row : null; +} + +async function patchJobControlState( + ctx: Pick, + row: Doc<'nodeslide_agent_jobs'>, + next: NodeSlideJobRecord, +) { + await ctx.db.patch(row._id, { + status: next.status, + phase: next.phase, + progress: next.progress, + attempt: next.attempt, + updatedAt: next.updatedAt, + ...(next.error ? { error: next.error } : { error: undefined }), + ...(next.completedAt ? { completedAt: next.completedAt } : { completedAt: undefined }), + }); +} + +async function readDurableSession( + ctx: Pick, + jobId: string, +): Promise { + return (await ctx.runQuery(sessionsInternal.get, { + sessionId: nodeslideStableId('nsession', jobId), + })) as DurableSessionProjection | null; +} + +async function pauseDurableSession( + ctx: Pick, + job: NodeSlideJobRecord, +): Promise { + const session = await readDurableSession(ctx, job.id); + const durableJob = session?.jobs.find((candidate) => candidate.jobId === job.id); + if (!session || !durableJob || durableJob.status === 'paused') return; + // The durable reducer intentionally permits pausing only claimed work. Queued + // and retrying rows remain fenced by the public paused state until resume. + if (durableJob.status !== 'running') return; + await ctx.runMutation(sessionsInternal.applyCommand, { + sessionId: session.sessionId, + commandId: `pause:${job.id}:${durableJob.attempt}`, + command: { + type: 'transition', + expectedStateVersion: session.stateVersion, + requestBinding: session.requestBinding, + jobId: job.id, + toStatus: 'paused', + leaseId: nodeslideStableId('session_lease', job.id, String(durableJob.attempt)), + reason: 'Paused by the owner at a durable workflow boundary.', + }, + }); +} + +async function resumeDurableSession( + ctx: Pick, + session: DurableSessionProjection, + job: DurableSessionProjection['jobs'][number], +): Promise { + const issuedAt = Date.now(); + await ctx.runMutation(sessionsInternal.applyCommand, { + sessionId: session.sessionId, + commandId: `resume:${job.jobId}:${job.attempt}`, + command: { + type: 'resume', + expectedStateVersion: session.stateVersion, + requestBinding: session.requestBinding, + jobId: job.jobId, + lease: { + leaseId: nodeslideStableId('session_lease', job.jobId, String(job.attempt)), + workerId: nodeslideStableId('session_worker', job.jobId), + issuedAt, + expiresAt: issuedAt + SESSION_LEASE_MS, + }, + }, + }); +} + +function requiredJobId(value: string): string { + const clean = value.trim(); + if (!clean || clean.length > 256) throw new Error('Invalid NodeSlide job id.'); + return clean; +} diff --git a/convex/nodeslideJobWorkflow.test.ts b/convex/nodeslideJobWorkflow.test.ts new file mode 100644 index 0000000..587cbe7 --- /dev/null +++ b/convex/nodeslideJobWorkflow.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const workflowSource = readFileSync( + fileURLToPath(new URL('./nodeslideJobWorkflow.ts', import.meta.url)), + 'utf8', +); +const runnerSource = readFileSync( + fileURLToPath(new URL('./nodeslideJobRunner.ts', import.meta.url)), + 'utf8', +); + +describe('NodeSlide durable workflow claim ordering', () => { + it.each([ + ['create', 'export const createDeckJobWorkflow', 'executeCreateDeckInternal'], + ['edit', 'export const editProposalJobWorkflow', 'executeEditProposalInternal'], + ])( + 'keeps the %s job queued until the runner claims its durable lease', + (_kind, start, action) => { + const workflowStart = workflowSource.indexOf(start); + const actionStart = workflowSource.indexOf(action, workflowStart); + expect(workflowStart).toBeGreaterThanOrEqual(0); + expect(actionStart).toBeGreaterThan(workflowStart); + + const preActionCheckpoint = workflowSource.slice(workflowStart, actionStart); + expect(preActionCheckpoint).toContain("phase: 'planning'"); + expect(preActionCheckpoint).not.toContain("status: 'running'"); + + const runnerStart = runnerSource.indexOf(`export const ${action}`); + const runnerClaim = runnerSource.indexOf('claimAttemptInternal', runnerStart); + const runnerGenerating = runnerSource.indexOf("phase: 'generating'", runnerStart); + expect(runnerStart).toBeGreaterThanOrEqual(0); + expect(runnerClaim).toBeGreaterThan(runnerStart); + expect(runnerGenerating).toBeGreaterThan(runnerClaim); + }, + ); + + it.each([ + ['create', 'gate-create-deck', 'heartbeat-create-deck-result'], + ['edit', 'gate-edit-proposal', 'heartbeat-edit-proposal-result'], + ])('fences paused %s work before execution and before completion', (_kind, before, after) => { + expect(workflowSource).toContain(`name: '${before}'`); + expect(workflowSource).toContain(`name: '${after}'`); + expect(workflowSource.match(/if \(!before(?:Create|Edit)\.shouldRun\) return;/gu)).toHaveLength( + 2, + ); + expect(workflowSource.match(/if \(!after(?:Create|Edit)\.shouldRun\) return;/gu)).toHaveLength( + 2, + ); + }); +}); diff --git a/convex/nodeslideJobWorkflow.ts b/convex/nodeslideJobWorkflow.ts index d9c85d0..ba80db5 100644 --- a/convex/nodeslideJobWorkflow.ts +++ b/convex/nodeslideJobWorkflow.ts @@ -12,6 +12,8 @@ import { workflow } from './workflows'; const jobsInternal: any = (internal as any).nodeslideJobs; // biome-ignore lint/suspicious/noExplicitAny: generated Convex self-reference boundary const jobRunnerInternal: any = (internal as any).nodeslideJobRunner; +// biome-ignore lint/suspicious/noExplicitAny: generated Convex self-reference boundary +const jobControlInternal: any = (internal as any).nodeslideJobControl; export const createDeckJobWorkflow = workflow.define({ args: { @@ -25,18 +27,31 @@ export const createDeckJobWorkflow = workflow.define({ jobsInternal.checkpointInternal, { jobId: args.jobId, - status: 'running', phase: 'planning', progress: 5, }, { inline: true, name: 'checkpoint-planning' }, ); + const beforeCreate = (await step.runMutation( + jobControlInternal.heartbeatInternal, + { jobId: args.jobId }, + { inline: true, name: 'gate-create-deck' }, + )) as { shouldRun: boolean }; + if (!beforeCreate.shouldRun) return; + const result = (await step.runAction(jobRunnerInternal.executeCreateDeckInternal, args, { name: 'execute-create-deck', retry: { maxAttempts: 3, initialBackoffMs: 1_000, base: 2 }, })) as { deckId: string; conversationRunId: string; memoryIds: string[] }; + const afterCreate = (await step.runMutation( + jobControlInternal.heartbeatInternal, + { jobId: args.jobId }, + { inline: true, name: 'heartbeat-create-deck-result' }, + )) as { shouldRun: boolean }; + if (!afterCreate.shouldRun) return; + await step.runMutation( jobsInternal.completeCreateDeckInternal, { @@ -62,13 +77,19 @@ export const editProposalJobWorkflow = workflow.define({ jobsInternal.checkpointInternal, { jobId: args.jobId, - status: 'running', phase: 'planning', progress: 5, }, { inline: true, name: 'checkpoint-edit-planning' }, ); + const beforeEdit = (await step.runMutation( + jobControlInternal.heartbeatInternal, + { jobId: args.jobId }, + { inline: true, name: 'gate-edit-proposal' }, + )) as { shouldRun: boolean }; + if (!beforeEdit.shouldRun) return; + const result = (await step.runAction(jobRunnerInternal.executeEditProposalInternal, args, { name: 'execute-edit-proposal', retry: { maxAttempts: 3, initialBackoffMs: 1_000, base: 2 }, @@ -80,6 +101,13 @@ export const editProposalJobWorkflow = workflow.define({ memoryIds: string[]; }; + const afterEdit = (await step.runMutation( + jobControlInternal.heartbeatInternal, + { jobId: args.jobId }, + { inline: true, name: 'heartbeat-edit-proposal-result' }, + )) as { shouldRun: boolean }; + if (!afterEdit.shouldRun) return; + await step.runMutation( jobsInternal.completeEditProposalInternal, { diff --git a/convex/nodeslideJobs.ts b/convex/nodeslideJobs.ts index 4468777..6ba015e 100644 --- a/convex/nodeslideJobs.ts +++ b/convex/nodeslideJobs.ts @@ -1344,6 +1344,12 @@ function validateEditProposalRequest(request: NodeSlideEditProposalJobRequest): throw new Error('baseDeckVersion must be a non-negative finite number.'); } if (instruction.length === 0) throw new Error('NodeSlide edit instruction is required.'); + if ( + request.maxCostUsd !== undefined && + (!Number.isFinite(request.maxCostUsd) || request.maxCostUsd < 0 || request.maxCostUsd > 100) + ) { + throw new Error('Run spend ceiling must be a finite USD amount from $0 through $100.'); + } try { validateNodeSlideProviderChoice( 'propose_edit', @@ -1357,6 +1363,11 @@ function validateEditProposalRequest(request: NodeSlideEditProposalJobRequest): throw error; } if (request.webResearch) { + if (request.maxCostUsd !== undefined) { + throw new Error( + 'A hard dollar ceiling cannot include unpriced web-search egress yet. Turn Web off or clear the run spend ceiling.', + ); + } if (request.webResearchConsent !== NODESLIDE_WEB_RESEARCH_CONSENT) { throw new Error( 'Explicit web research consent is required before sending this query to search providers.', diff --git a/convex/nodeslideSessions.ts b/convex/nodeslideSessions.ts index fcee69b..9f24aa4 100644 --- a/convex/nodeslideSessions.ts +++ b/convex/nodeslideSessions.ts @@ -3,7 +3,6 @@ import { NODESLIDE_CAPABILITY_DIGEST_VERSION, NODESLIDE_DURABLE_JOB_STATUSES, NODESLIDE_DURABLE_SESSION_MAX_EVENTS, - NODESLIDE_DURABLE_SESSION_VERSION, NODESLIDE_REQUEST_BINDING_VERSION, type NodeSlideCapabilityDigestMetadata, type NodeSlideDurableJobEvent, @@ -328,6 +327,7 @@ export const applyCommand = internalMutation({ const next = reduceNodeSlideDurableSession(state, reducerCommand(command, now)); const durableEvent = next.eventSequence === state.eventSequence + 1 ? next.events.at(-1) : undefined; + const commandJobId = jobIdFromCommand(command); if ( next.stateVersion !== state.stateVersion + 1 || next.eventSequence < state.eventSequence || @@ -348,7 +348,7 @@ export const applyCommand = internalMutation({ eventSequence: next.eventSequence, egressEpoch: next.egressEpoch, requestBinding: next.requestBinding, - ...(jobIdFromCommand(command) ? { jobId: jobIdFromCommand(command) } : {}), + ...(commandJobId !== undefined ? { jobId: commandJobId } : {}), ...(durableEvent ? { event: durableEvent } : {}), ...(row.lastTransitionDigest ? { previousTransitionDigest: row.lastTransitionDigest } : {}), occurredAt: now, @@ -364,7 +364,7 @@ export const applyCommand = internalMutation({ eventSequence: next.eventSequence, egressEpoch: next.egressEpoch, requestBinding: next.requestBinding, - ...(jobIdFromCommand(command) ? { jobId: jobIdFromCommand(command) } : {}), + ...(commandJobId !== undefined ? { jobId: commandJobId } : {}), ...(durableEvent ? { event: durableEvent } : {}), ...(row.lastTransitionDigest ? { previousTransitionDigest: row.lastTransitionDigest } : {}), transitionDigest, @@ -1060,39 +1060,39 @@ function sameJournalBinding( function assertModelResultEnvelope(value: unknown, callId: string): void { const envelope = jsonRecord(value, 'model result envelope'); - const accounting = jsonRecord(envelope.accounting, 'model result accounting'); + const accounting = jsonRecord(envelope['accounting'], 'model result accounting'); assertOnlyKeys(accounting, ['budgetId', 'callId', 'disposition', 'ledger'], 'accounting'); - assertBoundedString(accounting.budgetId, 'accounting budget id', 512); - assertBoundedString(accounting.callId, 'accounting call id', 256); - if (accounting.callId !== callId) { + assertBoundedString(accounting['budgetId'], 'accounting budget id', 512); + assertBoundedString(accounting['callId'], 'accounting call id', 256); + if (accounting['callId'] !== callId) { integrityFailure('provider result accounting call id does not match the journal call'); } if ( - typeof accounting.disposition !== 'string' || - !MODEL_RESULT_DISPOSITIONS.has(accounting.disposition) + typeof accounting['disposition'] !== 'string' || + !MODEL_RESULT_DISPOSITIONS.has(accounting['disposition']) ) { integrityFailure('model result accounting disposition is invalid'); } - if (accounting.ledger !== undefined) assertModelResultLedger(accounting.ledger); + if (accounting['ledger'] !== undefined) assertModelResultLedger(accounting['ledger']); - if (envelope.ok === true) { + if (envelope['ok'] === true) { assertOnlyKeys(envelope, ['ok', 'value', 'telemetry', 'accounting'], 'successful result'); if (!Object.hasOwn(envelope, 'value') || !Object.hasOwn(envelope, 'telemetry')) { integrityFailure('successful model result envelope is incomplete'); } - assertModelResultTelemetry(envelope.telemetry); + assertModelResultTelemetry(envelope['telemetry']); return; } - if (envelope.ok !== false) integrityFailure('model result envelope status is invalid'); + if (envelope['ok'] !== false) integrityFailure('model result envelope status is invalid'); assertOnlyKeys(envelope, ['ok', 'reason', 'code', 'telemetry', 'accounting'], 'failed result'); - assertBoundedString(envelope.reason, 'model result reason', 4_000); + assertBoundedString(envelope['reason'], 'model result reason', 4_000); if ( - envelope.code !== undefined && - (typeof envelope.code !== 'string' || !MODEL_RESULT_FAILURE_CODES.has(envelope.code)) + envelope['code'] !== undefined && + (typeof envelope['code'] !== 'string' || !MODEL_RESULT_FAILURE_CODES.has(envelope['code'])) ) { integrityFailure('model result failure code is invalid'); } - if (envelope.telemetry !== undefined) assertModelResultTelemetry(envelope.telemetry); + if (envelope['telemetry'] !== undefined) assertModelResultTelemetry(envelope['telemetry']); } function assertModelResultTelemetry(value: unknown): void { @@ -1110,30 +1110,30 @@ function assertModelResultTelemetry(value: unknown): void { ], 'telemetry', ); - assertBoundedString(telemetry.provider, 'telemetry provider', 256); - assertBoundedString(telemetry.model, 'telemetry model', 256); - assertNonnegativeInteger(telemetry.costMicroUsd, 'telemetry cost'); - assertNonnegativeInteger(telemetry.inputTokens, 'telemetry input tokens'); - assertNonnegativeInteger(telemetry.outputTokens, 'telemetry output tokens'); + assertBoundedString(telemetry['provider'], 'telemetry provider', 256); + assertBoundedString(telemetry['model'], 'telemetry model', 256); + assertNonnegativeInteger(telemetry['costMicroUsd'], 'telemetry cost'); + assertNonnegativeInteger(telemetry['inputTokens'], 'telemetry input tokens'); + assertNonnegativeInteger(telemetry['outputTokens'], 'telemetry output tokens'); if ( - telemetry.reasoningEffort !== undefined && - (typeof telemetry.reasoningEffort !== 'string' || - !MODEL_RESULT_REASONING_EFFORTS.has(telemetry.reasoningEffort)) + telemetry['reasoningEffort'] !== undefined && + (typeof telemetry['reasoningEffort'] !== 'string' || + !MODEL_RESULT_REASONING_EFFORTS.has(telemetry['reasoningEffort'])) ) { integrityFailure('telemetry reasoning effort is invalid'); } - if (telemetry.attempts === undefined) return; - if (!Array.isArray(telemetry.attempts) || telemetry.attempts.length > 2) { + if (telemetry['attempts'] === undefined) return; + if (!Array.isArray(telemetry['attempts']) || telemetry['attempts'].length > 2) { integrityFailure('telemetry attempts are invalid'); } - for (const value of telemetry.attempts) { + for (const value of telemetry['attempts']) { const attempt = jsonRecord(value, 'model result telemetry attempt'); assertOnlyKeys( attempt, ['attempt', 'attempted', 'settled', 'ambiguous', 'unreconciled', 'elapsedMs'], 'telemetry attempt', ); - if (attempt.attempt !== 'initial' && attempt.attempt !== 'repair') { + if (attempt['attempt'] !== 'initial' && attempt['attempt'] !== 'repair') { integrityFailure('telemetry attempt kind is invalid'); } for (const field of ['attempted', 'settled', 'ambiguous', 'unreconciled'] as const) { @@ -1141,14 +1141,14 @@ function assertModelResultTelemetry(value: unknown): void { integrityFailure(`telemetry attempt ${field} is invalid`); } } - assertNonnegativeInteger(attempt.elapsedMs, 'telemetry attempt elapsed time'); + assertNonnegativeInteger(attempt['elapsedMs'], 'telemetry attempt elapsed time'); } } function assertModelResultLedger(value: unknown): void { const ledger = jsonRecord(value, 'model result ledger'); assertOnlyKeys(ledger, ['budget', 'call'], 'ledger'); - const budget = jsonRecord(ledger.budget, 'model result budget'); + const budget = jsonRecord(ledger['budget'], 'model result budget'); assertOnlyKeys( budget, [ @@ -1162,30 +1162,30 @@ function assertModelResultLedger(value: unknown): void { ], 'ledger budget', ); - assertBoundedString(budget.id, 'ledger budget id', 512); - if (budget.status !== 'open' && budget.status !== 'finalized') { + assertBoundedString(budget['id'], 'ledger budget id', 512); + if (budget['status'] !== 'open' && budget['status'] !== 'finalized') { integrityFailure('ledger budget status is invalid'); } - assertNonnegativeInteger(budget.revision, 'ledger budget revision'); - assertBoundedString(budget.stateDigest, 'ledger budget state digest', 256); - assertNonnegativeInteger(budget.actualMicroUsd, 'ledger actual cost'); - assertNonnegativeInteger(budget.reservedMicroUsd, 'ledger reserved cost'); - assertNonnegativeInteger(budget.unreconciledMicroUsd, 'ledger unreconciled cost'); - - if (ledger.call === undefined) return; - const call = jsonRecord(ledger.call, 'model result ledger call'); + assertNonnegativeInteger(budget['revision'], 'ledger budget revision'); + assertBoundedString(budget['stateDigest'], 'ledger budget state digest', 256); + assertNonnegativeInteger(budget['actualMicroUsd'], 'ledger actual cost'); + assertNonnegativeInteger(budget['reservedMicroUsd'], 'ledger reserved cost'); + assertNonnegativeInteger(budget['unreconciledMicroUsd'], 'ledger unreconciled cost'); + + if (ledger['call'] === undefined) return; + const call = jsonRecord(ledger['call'], 'model result ledger call'); assertOnlyKeys( call, ['callId', 'status', 'quoteMicroUsd', 'providerSafeOutputTokenCeiling', 'providerTimeoutMs'], 'ledger call', ); - assertBoundedString(call.callId, 'ledger call id', 256); - if (!['reserved', 'unreconciled', 'settled', 'released'].includes(String(call.status))) { + assertBoundedString(call['callId'], 'ledger call id', 256); + if (!['reserved', 'unreconciled', 'settled', 'released'].includes(String(call['status']))) { integrityFailure('ledger call status is invalid'); } - assertNonnegativeInteger(call.quoteMicroUsd, 'ledger call quote'); - assertNonnegativeInteger(call.providerSafeOutputTokenCeiling, 'ledger output ceiling'); - assertNonnegativeInteger(call.providerTimeoutMs, 'ledger timeout'); + assertNonnegativeInteger(call['quoteMicroUsd'], 'ledger call quote'); + assertNonnegativeInteger(call['providerSafeOutputTokenCeiling'], 'ledger output ceiling'); + assertNonnegativeInteger(call['providerTimeoutMs'], 'ledger timeout'); } function canonicalJsonValue(value: unknown, active: Set, depth: number): unknown { diff --git a/convex/nodeslideUploads.test.ts b/convex/nodeslideUploads.test.ts new file mode 100644 index 0000000..cd1d8e8 --- /dev/null +++ b/convex/nodeslideUploads.test.ts @@ -0,0 +1,487 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { + approveUpload, + deleteUpload, + getUploadMetadata, + listUploadMetadata, + materializeNodeSlideStoredText, + prepareUpload, + registerUpload, + rejectUpload, + requireApprovedUploadStorageId, +} from './nodeslideUploads'; +import type { PreparedNodeSlideUpload } from './nodeslideUploads'; + +const OWNER_ACCESS_KEY = 'a'.repeat(43); +const OTHER_OWNER_ACCESS_KEY = 'b'.repeat(43); +const DECK_ID = 'deck_1'; +const SESSION_ID = 'session:1'; +const DIGEST = 'stored-sha256-digest'; +const NOW = 1_752_000_000_000; + +type StoredRow = Record & { _id: string; _creationTime: number }; +type StorageMetadata = { + _id: Id<'_storage'>; + _creationTime: number; + sha256: string; + size: number; + contentType?: string; +}; + +class MemoryIndex { + readonly filters: Array<{ field: string; value: unknown }> = []; + + eq(field: string, value: unknown): this { + this.filters.push({ field, value }); + return this; + } +} + +class MemoryQuery { + private filters: ReadonlyArray<{ field: string; value: unknown }> = []; + private descending = false; + + constructor( + private readonly database: MemoryDatabase, + private readonly tableName: string, + ) {} + + withIndex(_name: string, configure: (index: MemoryIndex) => unknown): this { + const index = new MemoryIndex(); + configure(index); + this.filters = index.filters; + return this; + } + + order(direction: 'asc' | 'desc'): this { + this.descending = direction === 'desc'; + return this; + } + + async first(): Promise { + return this.matches()[0] ?? null; + } + + async unique(): Promise { + const matches = this.matches(); + if (matches.length > 1) throw new Error(`Expected unique ${this.tableName} row.`); + return matches[0] ?? null; + } + + async take(limit: number): Promise { + return this.matches().slice(0, limit); + } + + private matches(): StoredRow[] { + const matches = this.database + .rows(this.tableName) + .filter((row) => this.filters.every(({ field, value }) => row[field] === value)); + if (this.descending) { + return matches.sort((left, right) => Number(right.updatedAt) - Number(left.updatedAt)); + } + return matches; + } +} + +class MemoryDatabase { + private readonly tables = new Map(); + private readonly storage = new Map(); + private sequence = 0; + + readonly system: { + get: (tableName: '_storage', storageId: Id<'_storage'>) => Promise; + }; + + constructor() { + this.system = { + get: async (_tableName: '_storage', storageId: Id<'_storage'>) => + this.storage.get(storageId) ?? null, + }; + } + + query(tableName: string): MemoryQuery { + return new MemoryQuery(this, tableName); + } + + async insert(tableName: string, value: object): Promise { + return this.seed(tableName, value)._id; + } + + async patch(rowId: string, fields: object): Promise { + const row = this.rowById(rowId); + for (const [field, value] of Object.entries(fields)) { + if (value === undefined) delete row[field]; + else row[field] = structuredClone(value); + } + } + + async delete(rowId: string): Promise { + for (const [tableName, rows] of this.tables) { + const index = rows.findIndex((row) => row._id === rowId); + if (index >= 0) { + rows.splice(index, 1); + this.tables.set(tableName, rows); + return; + } + } + throw new Error(`Missing row ${rowId}.`); + } + + seed(tableName: string, value: object): StoredRow { + this.sequence += 1; + const row = { + ...structuredClone(value), + _id: `${tableName}:${this.sequence}`, + _creationTime: this.sequence, + } as StoredRow; + const rows = this.tables.get(tableName) ?? []; + rows.push(row); + this.tables.set(tableName, rows); + return row; + } + + seedStorage( + storageId: Id<'_storage'>, + metadata: Omit, + ): void { + this.storage.set(storageId, { + _id: storageId, + _creationTime: ++this.sequence, + ...metadata, + }); + } + + deleteStorage(storageId: Id<'_storage'>): void { + this.storage.delete(storageId); + } + + rows(tableName: string): StoredRow[] { + return [...(this.tables.get(tableName) ?? [])]; + } + + private rowById(rowId: string): StoredRow { + for (const rows of this.tables.values()) { + const row = rows.find((candidate) => candidate._id === rowId); + if (row) return row; + } + throw new Error(`Missing row ${rowId}.`); + } +} + +class MemoryStorage { + private uploadSequence = 0; + readonly deletes: Id<'_storage'>[] = []; + + constructor(private readonly database: MemoryDatabase) {} + + async generateUploadUrl(): Promise { + this.uploadSequence += 1; + return `https://upload.example/${this.uploadSequence}`; + } + + async delete(storageId: Id<'_storage'>): Promise { + this.deletes.push(storageId); + this.database.deleteStorage(storageId); + } +} + +type RegisteredHandler = (ctx: Ctx, args: Args) => Promise; + +function registeredHandler( + value: unknown, +): RegisteredHandler { + const handler = (value as { _handler?: unknown })._handler; + if (typeof handler !== 'function') throw new Error('Registered Convex handler is unavailable.'); + return handler as RegisteredHandler; +} + +type ScopeArgs = { + deckId: string; + ownerAccessKey: string; + clientSessionId: string; +}; + +type PrepareArgs = ScopeArgs & { + fileName: string; + contentType: string; + byteSize: number; + contentDigest: string; + idempotencyKey: string; +}; + +type RegisterArgs = ScopeArgs & { + uploadId: string; + storageId: Id<'_storage'>; + idempotencyKey: string; +}; + +type UploadArgs = ScopeArgs & { uploadId: string }; +type ApproveArgs = UploadArgs & { contentDigest: string }; + +const prepareHandler = registeredHandler(prepareUpload); +const registerHandler = registeredHandler(registerUpload); +const approveHandler = registeredHandler(approveUpload); +const rejectHandler = registeredHandler(rejectUpload); +const deleteHandler = registeredHandler( + deleteUpload, +); +const getHandler = registeredHandler | null, QueryCtx>( + getUploadMetadata, +); +const listHandler = registeredHandler< + ScopeArgs & { limit?: number }, + Record[], + QueryCtx +>(listUploadMetadata); + +let database: MemoryDatabase; +let storage: MemoryStorage; + +function mutationContext(): MutationCtx { + return { db: database, storage } as unknown as MutationCtx; +} + +function queryContext(): QueryCtx { + return { db: database } as unknown as QueryCtx; +} + +function scope(overrides: Partial = {}): ScopeArgs { + return { + deckId: DECK_ID, + ownerAccessKey: OWNER_ACCESS_KEY, + clientSessionId: SESSION_ID, + ...overrides, + }; +} + +function prepareArgs(overrides: Partial = {}): PrepareArgs { + return { + ...scope(), + fileName: 'brief.pdf', + contentType: 'application/pdf', + byteSize: 1_024, + contentDigest: DIGEST, + idempotencyKey: 'request_1', + ...overrides, + }; +} + +function storageId(value: string): Id<'_storage'> { + return value as Id<'_storage'>; +} + +async function prepareAndRegister(id = storageId('storage_1')) { + const prepared = await prepareHandler(mutationContext(), prepareArgs()); + database.seedStorage(id, { + sha256: DIGEST, + size: 1_024, + contentType: 'application/pdf', + }); + await registerHandler(mutationContext(), { + ...scope(), + uploadId: prepared.upload.id, + storageId: id, + idempotencyKey: 'request_1', + }); + return prepared; +} + +describe('NodeSlide two-phase uploads', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + database = new MemoryDatabase(); + storage = new MemoryStorage(database); + database.seed('nodeslide_decks', { + id: DECK_ID, + ownerAccessKey: OWNER_ACCESS_KEY, + clientSessionId: SESSION_ID, + version: 1, + }); + }); + + it('prepares a quarantined upload intent and returns a short-lived URL', async () => { + const result = await prepareHandler(mutationContext(), prepareArgs()); + + expect(result).toMatchObject({ + uploadUrl: 'https://upload.example/1', + replayed: false, + upload: { + deckId: DECK_ID, + clientSessionId: SESSION_ID, + format: 'pdf', + lifecycleStatus: 'awaiting_upload', + securityStatus: 'pending', + quarantineStatus: 'quarantined', + modelAccessAllowed: false, + }, + }); + expect(database.rows('nodeslide_uploads')).toHaveLength(1); + expect(JSON.stringify(result)).not.toContain('storageId'); + }); + + it('requires both the owner capability and matching deck session', async () => { + await expect( + prepareHandler(mutationContext(), prepareArgs({ ownerAccessKey: OTHER_OWNER_ACCESS_KEY })), + ).rejects.toThrow(/owner access denied/i); + await expect( + prepareHandler(mutationContext(), prepareArgs({ clientSessionId: 'session:other' })), + ).rejects.toThrow(/session access denied/i); + expect(database.rows('nodeslide_uploads')).toHaveLength(0); + }); + + it('replays one intent safely and rejects idempotency-key drift', async () => { + const first = await prepareHandler(mutationContext(), prepareArgs()); + const replay = await prepareHandler(mutationContext(), prepareArgs()); + expect(replay.upload.id).toBe(first.upload.id); + expect(replay.replayed).toBe(true); + expect(replay.uploadUrl).toBe('https://upload.example/2'); + expect(database.rows('nodeslide_uploads')).toHaveLength(1); + + await expect( + prepareHandler(mutationContext(), prepareArgs({ byteSize: 2_048 })), + ).rejects.toThrow(/idempotency key/i); + }); + + it('verifies storage metadata and keeps a registered file quarantined', async () => { + const prepared = await prepareHandler(mutationContext(), prepareArgs()); + const stored = storageId('storage_bad'); + database.seedStorage(stored, { + sha256: DIGEST, + size: 2_048, + contentType: 'application/pdf', + }); + await expect( + registerHandler(mutationContext(), { + ...scope(), + uploadId: prepared.upload.id, + storageId: stored, + idempotencyKey: 'request_1', + }), + ).rejects.toThrow(/size/i); + + database.seedStorage(storageId('storage_good'), { + sha256: DIGEST, + size: 1_024, + contentType: 'application/pdf', + }); + const registered = (await registerHandler(mutationContext(), { + ...scope(), + uploadId: prepared.upload.id, + storageId: storageId('storage_good'), + idempotencyKey: 'request_1', + })) as Record; + expect(registered).toMatchObject({ + lifecycleStatus: 'registered', + securityStatus: 'pending', + quarantineStatus: 'quarantined', + modelAccessAllowed: false, + }); + }); + + it('denies model storage access until digest-bound approval releases quarantine', async () => { + const prepared = await prepareAndRegister(); + await expect( + requireApprovedUploadStorageId(queryContext(), { + ...scope(), + uploadId: prepared.upload.id, + }), + ).rejects.toThrow(/not approved/i); + await expect( + approveHandler(mutationContext(), { + ...scope(), + uploadId: prepared.upload.id, + contentDigest: 'wrong-digest', + }), + ).rejects.toThrow(/digest/i); + + const approved = (await approveHandler(mutationContext(), { + ...scope(), + uploadId: prepared.upload.id, + contentDigest: DIGEST, + })) as Record; + expect(approved).toMatchObject({ + lifecycleStatus: 'registered', + securityStatus: 'approved', + quarantineStatus: 'released', + modelAccessAllowed: true, + }); + await expect( + requireApprovedUploadStorageId(queryContext(), { + ...scope(), + uploadId: prepared.upload.id, + }), + ).resolves.toBe(storageId('storage_1')); + }); + + it('keeps owner metadata bounded and free of raw storage locators', async () => { + const prepared = await prepareAndRegister(); + const metadata = await getHandler(queryContext(), { + ...scope(), + uploadId: prepared.upload.id, + }); + const listed = await listHandler(queryContext(), { ...scope(), limit: 1_000 }); + expect(metadata).not.toHaveProperty('storageId'); + expect(metadata).not.toHaveProperty('idempotencyKey'); + expect(listed).toHaveLength(1); + expect(listed[0]).not.toHaveProperty('storageId'); + }); + + it('supports explicit rejection without releasing raw model access', async () => { + const prepared = await prepareAndRegister(); + const rejected = (await rejectHandler(mutationContext(), { + ...scope(), + uploadId: prepared.upload.id, + })) as Record; + expect(rejected).toMatchObject({ + securityStatus: 'rejected', + quarantineStatus: 'quarantined', + modelAccessAllowed: false, + }); + await expect( + requireApprovedUploadStorageId(queryContext(), { + ...scope(), + uploadId: prepared.upload.id, + }), + ).rejects.toThrow(/not approved/i); + }); + + it('deletes both storage and metadata and treats retry as success', async () => { + const prepared = await prepareAndRegister(); + const args = { ...scope(), uploadId: prepared.upload.id }; + await expect(deleteHandler(mutationContext(), args)).resolves.toEqual({ + deleted: true, + uploadId: prepared.upload.id, + }); + expect(storage.deletes).toEqual([storageId('storage_1')]); + expect(database.rows('nodeslide_uploads')).toHaveLength(0); + await expect(deleteHandler(mutationContext(), args)).resolves.toEqual({ + deleted: true, + uploadId: prepared.upload.id, + }); + expect(storage.deletes).toHaveLength(1); + }); +}); + +describe('NodeSlide stored text materialization', () => { + it('preserves full data shape while bounding the model-readable preview', () => { + const content = [ + 'label,value', + ...Array.from({ length: 1_000 }, (_, index) => `Row ${index},${index}`), + ].join('\n'); + const result = materializeNodeSlideStoredText(new TextEncoder().encode(content), 'csv'); + + expect(result.truncated).toBe(true); + expect(new TextEncoder().encode(result.preview).byteLength).toBeLessThanOrEqual(7_200); + expect(result.rowCount).toBe(1_000); + expect(result.columns).toEqual(['label', 'value']); + }); + + it('rejects malformed JSON before it can become agent context', () => { + expect(() => + materializeNodeSlideStoredText(new TextEncoder().encode('{"unsafe":'), 'json'), + ).toThrow('Stored JSON is malformed.'); + }); +}); diff --git a/convex/nodeslideUploads.ts b/convex/nodeslideUploads.ts new file mode 100644 index 0000000..a6dcf07 --- /dev/null +++ b/convex/nodeslideUploads.ts @@ -0,0 +1,538 @@ +import { v } from 'convex/values'; +import { + type NodeSlideStoredAttachmentMetadata, + nodeSlideDataAttachmentShape, +} from '../shared/nodeslideAttachments'; +import { internal } from './_generated/api'; +import type { Doc, Id } from './_generated/dataModel'; +import type { MutationCtx, QueryCtx } from './_generated/server'; +import { action, internalQuery, mutation, query } from './_generated/server'; +import { requireOwnerAccess } from './lib/nodeslideAccess'; +import { nodeslideContentDigest, nodeslideStableId } from './lib/nodeslideIds'; +import { + NODESLIDE_UPLOAD_LIMITS, + assertNodeSlideStoredFileMatches, + assertNodeSlideUploadModelAccessible, + isNodeSlideUploadModelAccessible, + nodeSlideUploadRequestFingerprint, + validateNodeSlideUploadRequest, +} from './lib/nodeslideUploadPolicy'; + +type UploadReadCtx = Pick | Pick; + +export type PreparedNodeSlideUpload = { + upload: NodeSlideStoredAttachmentMetadata; + uploadUrl?: string; + replayed: boolean; +}; + +export const prepareUpload = mutation({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + fileName: v.string(), + contentType: v.string(), + byteSize: v.number(), + contentDigest: v.string(), + idempotencyKey: v.string(), + }, + handler: async (ctx, args): Promise => { + const deck = await requireOwnerSession( + ctx, + args.deckId, + args.ownerAccessKey, + args.clientSessionId, + ); + const request = validateNodeSlideUploadRequest(args); + const requestFingerprint = nodeSlideUploadRequestFingerprint(deck.id, request); + const existing = await ctx.db + .query('nodeslide_uploads') + .withIndex('by_deck_idempotency', (index) => + index.eq('deckId', deck.id).eq('idempotencyKey', request.idempotencyKey), + ) + .unique(); + + if (existing) { + if ( + existing.clientSessionId !== request.clientSessionId || + existing.requestFingerprint !== requestFingerprint + ) { + throw new Error('NodeSlide upload idempotency key was reused for a different request.'); + } + if (existing.lifecycleStatus === 'registered') { + return { upload: publicUploadMetadata(existing), replayed: true }; + } + return { + upload: publicUploadMetadata(existing), + uploadUrl: await ctx.storage.generateUploadUrl(), + replayed: true, + }; + } + + const now = Date.now(); + const row = { + id: nodeslideStableId('upload', deck.id, request.idempotencyKey), + deckId: deck.id, + clientSessionId: request.clientSessionId, + fileName: request.fileName, + format: request.format, + contentType: request.contentType, + byteSize: request.byteSize, + contentDigest: request.contentDigest, + idempotencyKey: request.idempotencyKey, + requestFingerprint, + lifecycleStatus: 'awaiting_upload' as const, + securityStatus: 'pending' as const, + quarantineStatus: 'quarantined' as const, + createdAt: now, + updatedAt: now, + }; + await ctx.db.insert('nodeslide_uploads', row); + return { + upload: publicUploadMetadata(row), + uploadUrl: await ctx.storage.generateUploadUrl(), + replayed: false, + }; + }, +}); + +export const registerUpload = mutation({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + uploadId: v.string(), + storageId: v.id('_storage'), + idempotencyKey: v.string(), + }, + handler: async (ctx, args): Promise => { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const upload = await requireOwnedUpload(ctx, args.deckId, args.clientSessionId, args.uploadId); + if (upload.idempotencyKey !== requireBoundedKey(args.idempotencyKey)) { + throw new Error('NodeSlide upload registration is not bound to the prepared request.'); + } + + const storedFile = await ctx.db.system.get('_storage', args.storageId); + assertNodeSlideStoredFileMatches(upload, storedFile); + + if (upload.lifecycleStatus === 'registered') { + if (upload.storageId !== args.storageId) { + await ctx.storage.delete(args.storageId); + } + return publicUploadMetadata(upload); + } + + const claimed = await ctx.db + .query('nodeslide_uploads') + .withIndex('by_storage', (index) => index.eq('storageId', args.storageId)) + .unique(); + if (claimed && claimed._id !== upload._id) { + throw new Error('NodeSlide stored file is already registered to another upload.'); + } + + const now = Date.now(); + const registered = { + ...upload, + storageId: args.storageId, + lifecycleStatus: 'registered' as const, + securityStatus: 'pending' as const, + quarantineStatus: 'quarantined' as const, + registeredAt: now, + updatedAt: now, + }; + await ctx.db.patch(upload._id, { + storageId: args.storageId, + lifecycleStatus: registered.lifecycleStatus, + securityStatus: registered.securityStatus, + quarantineStatus: registered.quarantineStatus, + registeredAt: now, + updatedAt: now, + }); + return publicUploadMetadata(registered); + }, +}); + +export const approveUpload = mutation({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + uploadId: v.string(), + contentDigest: v.string(), + }, + handler: async (ctx, args): Promise => { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const upload = await requireOwnedUpload(ctx, args.deckId, args.clientSessionId, args.uploadId); + if (upload.contentDigest !== args.contentDigest.trim()) { + throw new Error('NodeSlide upload approval digest does not match the registered file.'); + } + if (upload.lifecycleStatus !== 'registered' || !upload.storageId) { + throw new Error('NodeSlide upload must be registered before approval.'); + } + if (upload.securityStatus === 'rejected') { + throw new Error('NodeSlide rejected upload cannot be approved.'); + } + if (isNodeSlideUploadModelAccessible(upload)) return publicUploadMetadata(upload); + + const now = Date.now(); + const approved = { + ...upload, + securityStatus: 'approved' as const, + quarantineStatus: 'released' as const, + approvedAt: now, + updatedAt: now, + }; + await ctx.db.patch(upload._id, { + securityStatus: approved.securityStatus, + quarantineStatus: approved.quarantineStatus, + approvedAt: now, + updatedAt: now, + }); + return publicUploadMetadata(approved); + }, +}); + +export const rejectUpload = mutation({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + uploadId: v.string(), + }, + handler: async (ctx, args): Promise => { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const upload = await requireOwnedUpload(ctx, args.deckId, args.clientSessionId, args.uploadId); + if (upload.securityStatus === 'approved') { + throw new Error( + 'NodeSlide approved upload must be deleted rather than retroactively rejected.', + ); + } + if (upload.securityStatus === 'rejected') return publicUploadMetadata(upload); + + const now = Date.now(); + const rejected = { + ...upload, + securityStatus: 'rejected' as const, + quarantineStatus: 'quarantined' as const, + rejectedAt: now, + updatedAt: now, + }; + await ctx.db.patch(upload._id, { + securityStatus: rejected.securityStatus, + quarantineStatus: rejected.quarantineStatus, + rejectedAt: now, + updatedAt: now, + }); + return publicUploadMetadata(rejected); + }, +}); + +export const deleteUpload = mutation({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + uploadId: v.string(), + }, + handler: async (ctx, args): Promise<{ deleted: true; uploadId: string }> => { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const upload = await findUpload(ctx, args.uploadId); + if (!upload) return { deleted: true, uploadId: args.uploadId }; + assertUploadScope(upload, args.deckId, args.clientSessionId); + if (upload.storageId) await ctx.storage.delete(upload.storageId); + await ctx.db.delete(upload._id); + return { deleted: true, uploadId: upload.id }; + }, +}); + +export const getUploadMetadata = query({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + uploadId: v.string(), + }, + handler: async (ctx, args): Promise => { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const upload = await findUpload(ctx, args.uploadId); + if (!upload) return null; + assertUploadScope(upload, args.deckId, args.clientSessionId); + return publicUploadMetadata(upload); + }, +}); + +export const listUploadMetadata = query({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + limit: v.optional(v.number()), + }, + handler: async (ctx, args): Promise => { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const limit = boundedListLimit(args.limit); + const uploads = await ctx.db + .query('nodeslide_uploads') + .withIndex('by_deck_updated', (index) => index.eq('deckId', args.deckId)) + .order('desc') + .take(limit); + return uploads + .filter((upload) => upload.clientSessionId === args.clientSessionId) + .map(publicUploadMetadata); + }, +}); + +/** + * Converts an approved stored text/data upload into the same owner-gated source + * and immutable revision used by explicit agent read context. Raw storage IDs + * never cross the action boundary. Larger files retain their exact digest and + * shape while exposing only a bounded, visibly labelled preview to the model. + */ +export const materializeApprovedTextUpload = action({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + uploadId: v.string(), + }, + handler: async (ctx, args): Promise<{ id: string; kind: 'source'; label: string }> => { + const upload = await ctx.runQuery( + internal.nodeslideUploads.getApprovedUploadForMaterializationInternal, + args, + ); + if (!upload) throw new Error('NodeSlide approved upload is unavailable.'); + if (!isNodeSlideStoredTextFormat(upload.format)) { + throw new Error('This stored format does not yet have a model-readable extractor.'); + } + const blob = await ctx.storage.get(upload.storageId); + if (!blob) throw new Error('NodeSlide approved upload storage is unavailable.'); + const bytes = new Uint8Array(await blob.arrayBuffer()); + const materialized = materializeNodeSlideStoredText(bytes, upload.format); + return ctx.runMutation(internal.nodeslide.attachStoredDataSourceInternal, { + deckId: args.deckId, + ownerAccessKey: args.ownerAccessKey, + title: upload.fileName, + format: upload.format, + preview: materialized.preview, + previewTruncated: materialized.truncated, + contentDigest: nodeslideContentDigest(bytes), + byteSize: bytes.byteLength, + ...(materialized.rowCount !== undefined ? { rowCount: materialized.rowCount } : {}), + ...(materialized.columns ? { columns: materialized.columns } : {}), + }); + }, +}); + +export const getApprovedUploadForMaterializationInternal = internalQuery({ + args: { + deckId: v.string(), + ownerAccessKey: v.string(), + clientSessionId: v.string(), + uploadId: v.string(), + }, + handler: async (ctx, args) => { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const upload = await requireOwnedUpload(ctx, args.deckId, args.clientSessionId, args.uploadId); + assertNodeSlideUploadModelAccessible(upload); + if (!upload.storageId) throw new Error('NodeSlide approved upload storage is unavailable.'); + return { + storageId: upload.storageId, + fileName: upload.fileName, + format: upload.format, + contentType: upload.contentType, + byteSize: upload.byteSize, + contentDigest: upload.contentDigest, + }; + }, +}); + +const MODEL_PREVIEW_BYTES = 7_200; + +function isNodeSlideStoredTextFormat( + value: NodeSlideStoredAttachmentMetadata['format'], +): value is 'csv' | 'json' | 'txt' { + return value === 'csv' || value === 'json' || value === 'txt'; +} + +export function materializeNodeSlideStoredText( + bytes: Uint8Array, + format: 'csv' | 'json' | 'txt', +): { + preview: string; + truncated: boolean; + rowCount?: number; + columns?: string[]; +} { + let content: string; + try { + content = new TextDecoder('utf-8', { fatal: true }).decode(bytes); + } catch { + throw new Error('Stored data must be valid UTF-8 text.'); + } + content = content + .replace(/^\uFEFF/u, '') + .replace(/\r\n?/g, '\n') + .trim(); + if (!content) throw new Error('Stored data file is empty.'); + if (content.includes('\u0000')) throw new Error('Stored data contains invalid NUL bytes.'); + if (format === 'json') { + try { + JSON.parse(content); + } catch { + throw new Error('Stored JSON is malformed.'); + } + } + const shape = nodeSlideDataAttachmentShape(content, format); + const preview = boundedUtf8Prefix(content, MODEL_PREVIEW_BYTES); + return { + preview, + truncated: new TextEncoder().encode(content).byteLength > MODEL_PREVIEW_BYTES, + ...(shape.rowCount !== undefined ? { rowCount: shape.rowCount } : {}), + ...(shape.columns ? { columns: shape.columns } : {}), + }; +} + +function boundedUtf8Prefix(value: string, maxBytes: number): string { + const encoder = new TextEncoder(); + if (encoder.encode(value).byteLength <= maxBytes) return value; + let low = 0; + let high = value.length; + while (low < high) { + const middle = Math.ceil((low + high) / 2); + if (encoder.encode(value.slice(0, middle)).byteLength <= maxBytes) low = middle; + else high = middle - 1; + } + return value.slice(0, low).trimEnd(); +} + +/** + * The only server-side bridge from upload metadata to raw storage. Future model + * actions must call this gate instead of reading storage IDs from database rows. + */ +export async function requireApprovedUploadStorageId( + ctx: UploadReadCtx, + args: { + deckId: string; + ownerAccessKey: string; + clientSessionId: string; + uploadId: string; + }, +): Promise> { + await requireOwnerSession(ctx, args.deckId, args.ownerAccessKey, args.clientSessionId); + const upload = await requireOwnedUpload(ctx, args.deckId, args.clientSessionId, args.uploadId); + assertNodeSlideUploadModelAccessible(upload); + if (!upload.storageId) throw new Error('NodeSlide approved upload storage is unavailable.'); + return upload.storageId; +} + +async function requireOwnerSession( + ctx: UploadReadCtx, + deckId: string, + ownerAccessKey: string, + clientSessionId: string, +): Promise> { + const normalizedSessionId = requireBoundedSession(clientSessionId); + const deck = await requireOwnerAccess(ctx, deckId, ownerAccessKey); + if (deck.clientSessionId !== normalizedSessionId) { + throw new Error('NodeSlide upload session access denied.'); + } + return deck; +} + +async function requireOwnedUpload( + ctx: UploadReadCtx, + deckId: string, + clientSessionId: string, + uploadId: string, +): Promise> { + const upload = await findUpload(ctx, uploadId); + if (!upload) throw new Error('NodeSlide upload was not found.'); + assertUploadScope(upload, deckId, clientSessionId); + return upload; +} + +async function findUpload( + ctx: UploadReadCtx, + uploadId: string, +): Promise | null> { + const id = requireBoundedKey(uploadId); + return ctx.db + .query('nodeslide_uploads') + .withIndex('by_stable_id', (index) => index.eq('id', id)) + .unique(); +} + +function assertUploadScope( + upload: Pick, 'deckId' | 'clientSessionId'>, + deckId: string, + clientSessionId: string, +): void { + if (upload.deckId !== deckId || upload.clientSessionId !== clientSessionId) { + throw new Error('NodeSlide upload access denied.'); + } +} + +function publicUploadMetadata( + upload: Pick< + Doc<'nodeslide_uploads'>, + | 'id' + | 'deckId' + | 'clientSessionId' + | 'fileName' + | 'format' + | 'contentType' + | 'byteSize' + | 'contentDigest' + | 'lifecycleStatus' + | 'securityStatus' + | 'quarantineStatus' + | 'createdAt' + | 'updatedAt' + | 'registeredAt' + | 'approvedAt' + >, +): NodeSlideStoredAttachmentMetadata { + return { + id: upload.id, + deckId: upload.deckId, + clientSessionId: upload.clientSessionId, + fileName: upload.fileName, + format: upload.format, + contentType: upload.contentType, + byteSize: upload.byteSize, + contentDigest: upload.contentDigest, + lifecycleStatus: upload.lifecycleStatus, + securityStatus: upload.securityStatus, + quarantineStatus: upload.quarantineStatus, + modelAccessAllowed: isNodeSlideUploadModelAccessible(upload), + createdAt: upload.createdAt, + updatedAt: upload.updatedAt, + ...(upload.registeredAt !== undefined ? { registeredAt: upload.registeredAt } : {}), + ...(upload.approvedAt !== undefined ? { approvedAt: upload.approvedAt } : {}), + }; +} + +function requireBoundedSession(value: string): string { + const normalized = value.trim(); + if (!normalized || normalized.length > NODESLIDE_UPLOAD_LIMITS.clientSessionIdCharacters) { + throw new Error('NodeSlide upload session access denied.'); + } + return normalized; +} + +function requireBoundedKey(value: string): string { + const normalized = value.trim(); + if (!normalized || normalized.length > NODESLIDE_UPLOAD_LIMITS.idempotencyKeyCharacters) { + throw new Error('NodeSlide upload identifier is invalid.'); + } + return normalized; +} + +function boundedListLimit(value: number | undefined): number { + if (value === undefined) return 50; + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error('NodeSlide upload list limit is invalid.'); + } + return Math.min(value, NODESLIDE_UPLOAD_LIMITS.listItems); +} diff --git a/convex/schema.ts b/convex/schema.ts index d8f4804..84509c5 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -49,6 +49,29 @@ const nodeslideDecisionProvenanceValidator = v.union( }), ); +const nodeslideEvidenceBoxValidator = v.object({ + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + page: v.optional(v.number()), + pageCount: v.optional(v.number()), +}); + +const nodeslideClaimEvidenceRegionValidator = v.object({ + x: v.number(), + y: v.number(), + w: v.number(), + h: v.number(), + page: v.optional(v.number()), + pageCount: v.optional(v.number()), +}); + +const nodeslideEvidenceViewportValidator = v.object({ + width: v.number(), + height: v.number(), +}); + const nodeslidePreferenceEventTypeValidator = v.union( v.literal('variation_generated'), v.literal('variation_selected'), @@ -958,6 +981,84 @@ export default defineSchema({ .index('by_stable_id', ['id']) .index('by_deck', ['deckId']), + /** Append-only, content-addressed snapshots of exact source evidence. */ + nodeslide_source_revisions: defineTable({ + id: v.string(), + schema: v.literal('nodeslide.source-revision/v1'), + revisionDigest: v.string(), + ownerDigest: v.string(), + deckId: v.string(), + sourceId: v.string(), + title: v.string(), + url: v.optional(v.string()), + sourceType: v.union( + v.literal('internal'), + v.literal('url'), + v.literal('document'), + v.literal('spreadsheet'), + v.literal('note'), + ), + retrievedAt: v.number(), + citation: v.string(), + license: v.optional(v.string()), + format: v.optional( + v.union(v.literal('csv'), v.literal('json'), v.literal('txt'), v.literal('web')), + ), + contentDigest: v.string(), + byteSize: v.optional(v.number()), + rowCount: v.optional(v.number()), + columns: v.optional(v.array(v.string())), + provider: v.optional(v.string()), + retention: v.optional(v.union(v.literal('until_deleted'), v.literal('public_snapshot'))), + predecessorRevisionId: v.optional(v.string()), + predecessorRevisionDigest: v.optional(v.string()), + createdAt: v.number(), + }) + .index('by_stable_id', ['id']) + .index('by_deck_created', ['deckId', 'createdAt']) + .index('by_owner_created', ['ownerDigest', 'createdAt']) + .index('by_source_created', ['sourceId', 'createdAt']) + .index('by_source_content_digest', ['sourceId', 'contentDigest']), + + nodeslide_uploads: defineTable({ + id: v.string(), + deckId: v.string(), + clientSessionId: v.string(), + fileName: v.string(), + format: v.union( + v.literal('csv'), + v.literal('json'), + v.literal('txt'), + v.literal('md'), + v.literal('pdf'), + v.literal('docx'), + v.literal('xlsx'), + v.literal('png'), + v.literal('jpeg'), + v.literal('webp'), + v.literal('gif'), + v.literal('pptx'), + ), + contentType: v.string(), + byteSize: v.number(), + contentDigest: v.string(), + idempotencyKey: v.string(), + requestFingerprint: v.string(), + storageId: v.optional(v.id('_storage')), + lifecycleStatus: v.union(v.literal('awaiting_upload'), v.literal('registered')), + securityStatus: v.union(v.literal('pending'), v.literal('approved'), v.literal('rejected')), + quarantineStatus: v.union(v.literal('quarantined'), v.literal('released')), + createdAt: v.number(), + updatedAt: v.number(), + registeredAt: v.optional(v.number()), + approvedAt: v.optional(v.number()), + rejectedAt: v.optional(v.number()), + }) + .index('by_stable_id', ['id']) + .index('by_deck_updated', ['deckId', 'updatedAt']) + .index('by_deck_idempotency', ['deckId', 'idempotencyKey']) + .index('by_storage', ['storageId']), + nodeslide_agent_jobs: defineTable({ id: v.string(), kind: v.union(v.literal('create_deck'), v.literal('edit_proposal')), @@ -1292,6 +1393,22 @@ export default defineSchema({ toolName: v.optional(v.string()), toolCallId: v.optional(v.string()), parentMessageId: v.optional(v.string()), + agentRole: v.optional( + v.union( + v.literal('planner'), + v.literal('executor'), + v.literal('researcher'), + v.literal('validator'), + v.literal('analyst'), + v.literal('storyteller'), + v.literal('designer'), + v.literal('fact_checker'), + v.literal('reviewer'), + ), + ), + branchId: v.optional(v.string()), + branchLabel: v.optional(v.string()), + parallelGroupId: v.optional(v.string()), sourceIds: v.optional(v.array(v.string())), createdAt: v.number(), }) @@ -1376,6 +1493,103 @@ export default defineSchema({ .index('by_trace_sequence', ['traceId', 'sequence']) .index('by_deck_timestamp', ['deckId', 'timestamp']), + nodeslide_evidence_captures: defineTable({ + id: v.string(), + deckId: v.string(), + runId: v.string(), + traceId: v.string(), + spanId: v.string(), + parentSpanId: v.string(), + sourceId: v.string(), + /** Optional only for rows created before immutable revision binding shipped. */ + sourceRevisionId: v.optional(v.string()), + sourceRevisionDigest: v.optional(v.string()), + captureDigest: v.optional(v.string()), + url: v.string(), + goal: v.string(), + provider: v.string(), + status: v.union(v.literal('ready'), v.literal('failed'), v.literal('expired')), + error: v.optional(v.string()), + contentDigest: v.optional(v.string()), + stepCount: v.number(), + screenshotCount: v.number(), + pdfCount: v.number(), + createdAt: v.number(), + completedAt: v.optional(v.number()), + expiresAt: v.optional(v.number()), + }) + .index('by_stable_id', ['id']) + .index('by_deck_created', ['deckId', 'createdAt']) + .index('by_run_created', ['runId', 'createdAt']) + .index('by_trace_span', ['traceId', 'spanId']) + .index('by_source_created', ['sourceId', 'createdAt']) + .index('by_expiry', ['expiresAt']), + + nodeslide_evidence_steps: defineTable({ + id: v.string(), + captureId: v.string(), + deckId: v.string(), + runId: v.string(), + traceId: v.string(), + spanId: v.string(), + sequence: v.number(), + phase: v.string(), + label: v.string(), + status: v.union(v.literal('ok'), v.literal('warning'), v.literal('error')), + detail: v.optional(v.string()), + attachmentKind: v.optional(v.union(v.literal('screenshot'), v.literal('pdf'))), + screenshotStorageId: v.optional(v.id('_storage')), + pdfStorageId: v.optional(v.id('_storage')), + box: v.optional(nodeslideEvidenceBoxValidator), + /** Optional only for legacy rows. Missing scope is treated as source-level, never claim-level. */ + regionScope: v.optional(v.union(v.literal('source'), v.literal('claim'))), + selector: v.optional(v.string()), + quote: v.optional(v.string()), + viewport: v.optional(nodeslideEvidenceViewportValidator), + contentDigest: v.optional(v.string()), + attachmentDigest: v.optional(v.string()), + evidenceStepDigest: v.optional(v.string()), + startedAt: v.number(), + completedAt: v.optional(v.number()), + createdAt: v.number(), + }) + .index('by_stable_id', ['id']) + .index('by_capture_sequence', ['captureId', 'sequence']) + .index('by_run_sequence', ['runId', 'sequence']) + .index('by_trace_span_sequence', ['traceId', 'spanId', 'sequence']) + .index('by_deck_created', ['deckId', 'createdAt']), + + /** Append-only claim-to-region custody receipts. Ambiguous geometry is never stored here. */ + nodeslide_claim_evidence_receipts: defineTable({ + id: v.string(), + receiptId: v.string(), + schema: v.literal('nodeslide.claim-evidence-receipt/v1'), + receiptDigest: v.string(), + ownerDigest: v.string(), + deckId: v.string(), + patchId: v.string(), + traceId: v.optional(v.string()), + slideId: v.string(), + elementId: v.string(), + claimDigest: v.string(), + sourceRevisionId: v.string(), + sourceRevisionDigest: v.string(), + captureId: v.string(), + captureDigest: v.string(), + evidenceStepId: v.string(), + evidenceStepDigest: v.string(), + attachmentKind: v.union(v.literal('screenshot'), v.literal('pdf')), + attachmentDigest: v.string(), + region: nodeslideClaimEvidenceRegionValidator, + createdAt: v.number(), + }) + .index('by_stable_id', ['id']) + .index('by_deck_created', ['deckId', 'createdAt']) + .index('by_owner_created', ['ownerDigest', 'createdAt']) + .index('by_patch_created', ['patchId', 'createdAt']) + .index('by_claim_created', ['claimDigest', 'createdAt']) + .index('by_source_revision_created', ['sourceRevisionId', 'createdAt']), + nodeslide_validations: defineTable({ id: v.string(), deckId: v.string(), diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck-montage-verified.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck-montage-verified.png new file mode 100644 index 0000000..2a48c1f Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck-montage-verified.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck-montage.webp b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck-montage.webp new file mode 100644 index 0000000..9e5730a Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck-montage.webp differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck.inspect.ndjson b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck.inspect.ndjson new file mode 100644 index 0000000..c3844d4 --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/deck.inspect.ndjson @@ -0,0 +1,95 @@ +{"kind":"slide","id":"sl/h8vvsa","slide":1,"title":"NODESLIDE / OPENUI PHASE 0","textShapes":6} +{"kind":"textbox","id":"sh/qhwveh8b","slide":1,"name":"cover-eyebrow","text":"NODESLIDE / OPENUI PHASE 0","textPreview":"NODESLIDE / OPENUI PHASE 0","textChars":26,"textLines":1,"bbox":[42,40,620,34]} +{"kind":"textbox","id":"sh/dk7epwrm","slide":1,"name":"cover-title","text":"OpenUI makes the visual\ndecision visible before\nthe slide changes","textPreview":"OpenUI makes the visual | decision visible before | the slide changes","textChars":65,"textLines":3,"bbox":[42,165,1010,305]} +{"kind":"textbox","id":"sh/cjedgrq1","slide":1,"name":"cover-subtitle","text":"A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.","textPreview":"A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.","textChars":104,"textLines":1,"bbox":[42,520,840,92]} +{"kind":"shape","id":"sh/3e5wjm9k","slide":1,"bbox":[1075,40,163,20]} +{"kind":"textbox","id":"sh/idwvah8z","slide":1,"text":"LIVE LOCAL RUN","textPreview":"LIVE LOCAL RUN","textChars":14,"textLines":1,"bbox":[1062,72,176,24]} +{"kind":"textbox","id":"sh/pgnelwrq","slide":1,"name":"footer-label-1","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/4fedcrq5","slide":1,"name":"footer-page-1","text":"01","textPreview":"01","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"slide","id":"sl/eye12a","slide":2,"title":"Four incompatible units need a transformation ladder—not one axis","textShapes":25} +{"kind":"textbox","id":"sh/don6t4je","slide":2,"name":"slide-title-2","text":"Four incompatible units need a transformation ladder—not one axis","textPreview":"Four incompatible units need a transformation ladder—not one axis","textChars":65,"textLines":1,"bbox":[42,36,1196,92]} +{"kind":"textbox","id":"sh/qlcnipkn","slide":2,"name":"footer-label-2","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/rmloru18","slide":2,"name":"footer-page-2","text":"02","textPreview":"02","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"textbox","id":"sh/4ju5gf2x","slide":2,"text":"The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.","textPreview":"The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.","textChars":106,"textLines":1,"bbox":[42,125,1000,60]} +{"kind":"shape","id":"sh/pk3mpkj2","slide":2,"bbox":[42,247,262,285]} +{"kind":"textbox","id":"sh/2hcne5kr","slide":2,"text":"01","textPreview":"01","textChars":2,"textLines":1,"bbox":[62,268,40,24]} +{"kind":"textbox","id":"sh/3il4na1c","slide":2,"text":"REPLICATION","textPreview":"REPLICATION","textChars":11,"textLines":1,"bbox":[100,268,182,38]} +{"kind":"textbox","id":"sh/ofy5sj21","slide":2,"text":"200K copies","textPreview":"200K copies","textChars":11,"textLines":1,"bbox":[62,343,218,88]} +{"kind":"shape","id":"sh/9g761ojm","slide":2,"bbox":[62,451,218,0]} +{"kind":"textbox","id":"sh/lkn2d0bq","slide":2,"text":"copies","textPreview":"copies","textChars":6,"textLines":1,"bbox":[62,469,218,42]} +{"kind":"textbox","id":"sh/kjelkfa5","slide":2,"text":"→","textPreview":"→","textChars":1,"textLines":1,"bbox":[304,365,24,40]} +{"kind":"shape","id":"sh/zi5kbatk","slide":2,"bbox":[328,247,262,285]} +{"kind":"textbox","id":"sh/yhw3i5sz","slide":2,"text":"02","textPreview":"02","textChars":2,"textLines":1,"bbox":[348,268,40,24]} +{"kind":"textbox","id":"sh/xg3290be","slide":2,"text":"EQUIVALENT CAPACITY","textPreview":"EQUIVALENT CAPACITY","textChars":19,"textLines":1,"bbox":[386,268,182,38]} +{"kind":"textbox","id":"sh/cfulgvat","slide":2,"text":"50K coder\nequivalents","textPreview":"50K coder | equivalents","textChars":21,"textLines":2,"bbox":[348,343,218,88]} +{"kind":"shape","id":"sh/belk7qt8","slide":2,"bbox":[348,451,218,0]} +{"kind":"textbox","id":"sh/adc3els3","slide":2,"text":"coder equivalents","textPreview":"coder equivalents","textChars":17,"textLines":1,"bbox":[348,469,218,42]} +{"kind":"textbox","id":"sh/hc7mlkry","slide":2,"text":"→","textPreview":"→","textChars":1,"textLines":1,"bbox":[590,365,24,40]} +{"kind":"shape","id":"sh/wbylszad","slide":2,"bbox":[614,247,262,285]} +{"kind":"textbox","id":"sh/gf65o3i9","slide":2,"text":"03","textPreview":"03","textChars":2,"textLines":1,"bbox":[634,268,40,24]} +{"kind":"textbox","id":"sh/1gf6h8zu","slide":2,"text":"INDIVIDUAL ACCELERATION","textPreview":"INDIVIDUAL ACCELERATION","textChars":23,"textLines":1,"bbox":[672,268,182,38]} +{"kind":"textbox","id":"sh/etonmt0j","slide":2,"text":"30× speed","textPreview":"30× speed","textChars":9,"textLines":1,"bbox":[634,343,218,88]} +{"kind":"shape","id":"sh/fux4fyho","slide":2,"bbox":[634,451,218,0]} +{"kind":"textbox","id":"sh/srm5kjid","slide":2,"text":"individual speed multiplier","textPreview":"individual speed multiplier","textChars":27,"textLines":1,"bbox":[634,469,218,42]} +{"kind":"textbox","id":"sh/tsfmdozy","slide":2,"text":"→","textPreview":"→","textChars":1,"textLines":1,"bbox":[876,365,24,40]} +{"kind":"shape","id":"sh/6p4nit07","slide":2,"bbox":[900,247,262,285]} +{"kind":"textbox","id":"sh/rqd4behs","slide":2,"text":"04","textPreview":"04","textChars":2,"textLines":1,"bbox":[920,268,40,24]} +{"kind":"textbox","id":"sh/knm5gji1","slide":2,"text":"SYSTEM OUTCOME","textPreview":"SYSTEM OUTCOME","textChars":14,"textLines":1,"bbox":[958,268,182,38]} +{"kind":"textbox","id":"sh/5ovm9ozm","slide":2,"text":"4× research\nprogress","textPreview":"4× research | progress","textChars":20,"textLines":2,"bbox":[920,343,218,88]} +{"kind":"shape","id":"sh/nitozelo","slide":2,"bbox":[920,451,218,0]} +{"kind":"textbox","id":"sh/mhknq9k3","slide":2,"text":"overall progress multiplier","textPreview":"overall progress multiplier","textChars":27,"textLines":1,"bbox":[920,469,218,42]} +{"kind":"textbox","id":"sh/9kb61o3u","slide":2,"text":"USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED","textPreview":"USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED","textChars":43,"textLines":1,"bbox":[42,574,560,30]} +{"kind":"textbox","id":"sh/8j25sj29","slide":2,"text":"NO SHARED AXIS · MIXED UNITS","textPreview":"NO SHARED AXIS · MIXED UNITS","textChars":28,"textLines":1,"bbox":[830,574,408,30]} +{"kind":"slide","id":"sl/251tu4","slide":3,"title":"Selection crosses one governed boundary at a time","textShapes":17} +{"kind":"textbox","id":"sh/dwz29036","slide":3,"name":"slide-title-3","text":"Selection crosses one governed boundary at a time","textPreview":"Selection crosses one governed boundary at a time","textChars":49,"textLines":1,"bbox":[42,36,1196,92]} +{"kind":"textbox","id":"sh/qto3y5kf","slide":3,"name":"footer-label-3","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/ruxk7ql0","slide":3,"name":"footer-page-3","text":"03","textPreview":"03","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"textbox","id":"sh/0zql4f2h","slide":3,"text":"OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.","textPreview":"OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.","textChars":94,"textLines":1,"bbox":[42,126,1020,55]} +{"kind":"shape","id":"sh/10zmdk32","slide":3,"bbox":[78,355,1092,0]} +{"kind":"shape","id":"sh/ex8325kr","slide":3,"bbox":[78,346,18,18]} +{"kind":"textbox","id":"sh/zyh4balc","slide":3,"text":"01","textPreview":"01","textChars":2,"textLines":1,"bbox":[78,294,44,28]} +{"kind":"textbox","id":"sh/onal8f2t","slide":3,"text":"OPENUI LANG","textPreview":"OPENUI LANG","textChars":11,"textLines":1,"bbox":[78,390,245,34]} +{"kind":"textbox","id":"sh/9ojmhkje","slide":3,"text":"Render an allowlisted\nvisual decision","textPreview":"Render an allowlisted | visual decision","textChars":37,"textLines":2,"bbox":[78,436,250,84]} +{"kind":"shape","id":"sh/5g3etgne","slide":3,"bbox":[370,346,18,18]} +{"kind":"textbox","id":"sh/4fux0b6t","slide":3,"text":"02","textPreview":"02","textChars":2,"textLines":1,"bbox":[370,294,44,28]} +{"kind":"textbox","id":"sh/3elwr658","slide":3,"text":"VISUAL MATERIAL SPEC","textPreview":"VISUAL MATERIAL SPEC","textChars":20,"textLines":1,"bbox":[370,390,245,34]} +{"kind":"textbox","id":"sh/itcfy1on","slide":3,"text":"Validate units, labels,\nand provenance","textPreview":"Validate units, labels, | and provenance","textChars":38,"textLines":2,"bbox":[370,436,250,84]} +{"kind":"shape","id":"sh/dknexgna","slide":3,"bbox":[662,346,18,18]} +{"kind":"textbox","id":"sh/sjex4b6p","slide":3,"text":"03","textPreview":"03","textChars":2,"textLines":1,"bbox":[662,294,44,28]} +{"kind":"textbox","id":"sh/rilwvq5k","slide":3,"text":"SLIDELANG PROPOSAL","textPreview":"SLIDELANG PROPOSAL","textChars":18,"textLines":1,"bbox":[662,390,245,34]} +{"kind":"textbox","id":"sh/qhcf2loz","slide":3,"text":"Compile one editable\nadd-slide operation","textPreview":"Compile one editable | add-slide operation","textChars":40,"textLines":2,"bbox":[662,436,250,84]} +{"kind":"shape","id":"sh/toje1wn6","slide":3,"bbox":[954,346,18,18]} +{"kind":"textbox","id":"sh/8nadsr6l","slide":3,"text":"04","textPreview":"04","textChars":2,"textLines":1,"bbox":[954,294,44,28]} +{"kind":"textbox","id":"sh/kjy54vit","slide":3,"text":"HUMAN ACCEPT","textPreview":"HUMAN ACCEPT","textChars":12,"textLines":1,"bbox":[954,390,245,34]} +{"kind":"textbox","id":"sh/lk76xgze","slide":3,"text":"CAS + candidate receipt\nadvance v1 → v2","textPreview":"CAS + candidate receipt | advance v1 → v2","textChars":39,"textLines":2,"bbox":[954,436,250,84]} +{"kind":"shape","id":"sh/yhwn2l0n","slide":3,"bbox":[42,575,1196,55]} +{"kind":"textbox","id":"sh/ji5ovqh8","slide":3,"text":"Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id","textPreview":"Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id","textChars":91,"textLines":1,"bbox":[65,592,1150,28]} +{"kind":"slide","id":"sl/rbc51e","slide":4,"title":"The live interaction proved the boundary—not just the render","textShapes":10} +{"kind":"textbox","id":"sh/8z6p87al","slide":4,"name":"slide-title-4","text":"The live interaction proved the boundary—not just the render","textPreview":"The live interaction proved the boundary—not just the render","textChars":60,"textLines":1,"bbox":[42,36,1196,92]} +{"kind":"textbox","id":"sh/nyxozm90","slide":4,"name":"footer-label-4","text":"LOCAL PROOF · 2026-07-16","textPreview":"LOCAL PROOF · 2026-07-16","textChars":24,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/mxo76hsf","slide":4,"name":"footer-page-4","text":"04","textPreview":"04","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} +{"kind":"textbox","id":"sh/lwv6xcru","slide":4,"text":"Disposable local workspace · exact candidate preview corrected during dogfood","textPreview":"Disposable local workspace · exact candidate preview corrected during dogfood","textChars":77,"textLines":1,"bbox":[42,126,790,45]} +{"kind":"shape","id":"sh/zadov29o","slide":4,"bbox":[858,194,380,128]} +{"kind":"textbox","id":"sh/e94n2xs3","slide":4,"text":"15 / 15","textPreview":"15 / 15","textChars":7,"textLines":1,"bbox":[884,216,330,52]} +{"kind":"textbox","id":"sh/5sz69wry","slide":4,"text":"OpenUI + agent contract tests passed","textPreview":"OpenUI + agent contract tests passed","textChars":36,"textLines":1,"bbox":[884,274,330,36]} +{"kind":"shape","id":"sh/krqpgbat","slide":4,"bbox":[858,342,380,128]} +{"kind":"textbox","id":"sh/ahgr2dg7","slide":4,"text":"v1 → v2","textPreview":"v1 → v2","textChars":7,"textLines":1,"bbox":[884,364,330,52]} +{"kind":"textbox","id":"sh/bip8bixs","slide":4,"text":"Pending proposal stayed unapplied until Accept","textPreview":"Pending proposal stayed unapplied until Accept","textChars":46,"textLines":1,"bbox":[884,422,330,40]} +{"kind":"shape","id":"sh/wjy94nyd","slide":4,"bbox":[858,490,380,142]} +{"kind":"textbox","id":"sh/xk7qdsfy","slide":4,"text":"1 operation","textPreview":"1 operation","textChars":11,"textLines":1,"bbox":[884,512,330,46]} +{"kind":"textbox","id":"sh/mdwrydgb","slide":4,"text":"Editable add-slide patch\nwith a candidate validation receipt","textPreview":"Editable add-slide patch | with a candidate validation receipt","textChars":60,"textLines":2,"bbox":[884,566,330,58]} +{"kind":"image","id":"im/1kneh43i","slide":4,"name":"","alt":"NodeSlide Compare showing the baseline and the new OpenUI visual material proposal.","bbox":[42,194,780,438]} +{"kind":"slide","id":"sl/050391","slide":5,"title":"RELEASE BAR","textShapes":10} +{"kind":"textbox","id":"sh/yl0rylg3","slide":5,"text":"RELEASE BAR","textPreview":"RELEASE BAR","textChars":11,"textLines":1,"bbox":[42,40,260,30]} +{"kind":"textbox","id":"sh/l8rq90fu","slide":5,"text":"Adopt Phase 0 now.\nKeep the boundary.","textPreview":"Adopt Phase 0 now. | Keep the boundary.","textChars":37,"textLines":2,"bbox":[42,178,950,245]} +{"kind":"shape","id":"sh/kni90vy9","slide":5,"bbox":[42,492,350,0]} +{"kind":"textbox","id":"sh/7a98bax0","slide":5,"text":"01 OPENUI","textPreview":"01 OPENUI","textChars":10,"textLines":1,"bbox":[42,516,350,32]} +{"kind":"textbox","id":"sh/m90r25gf","slide":5,"text":"Bounded component library + visible provenance","textPreview":"Bounded component library + visible provenance","textChars":46,"textLines":1,"bbox":[42,558,350,70]} +{"kind":"shape","id":"sh/9crad0fq","slide":5,"bbox":[437,492,350,0]} +{"kind":"textbox","id":"sh/8bi94fyl","slide":5,"text":"02 SLIDELANG","textPreview":"02 SLIDELANG","textChars":13,"textLines":1,"bbox":[437,516,350,32]} +{"kind":"textbox","id":"sh/nep8z6xg","slide":5,"text":"Editable output + exact candidate validation","textPreview":"Editable output + exact candidate validation","textChars":44,"textLines":1,"bbox":[437,558,350,70]} +{"kind":"shape","id":"sh/2dwrq1gv","slide":5,"bbox":[832,492,350,0]} +{"kind":"textbox","id":"sh/wrypgv6l","slide":5,"text":"03 HUMAN REVIEW","textPreview":"03 HUMAN REVIEW","textChars":16,"textLines":1,"bbox":[832,516,350,32]} +{"kind":"textbox","id":"sh/xs7qp0nq","slide":5,"text":"No mutation before explicit Accept","textPreview":"No mutation before explicit Accept","textChars":34,"textLines":1,"bbox":[832,558,350,70]} +{"kind":"textbox","id":"sh/apg7elof","slide":5,"name":"footer-label-5","text":"NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI","textPreview":"NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI","textChars":47,"textLines":1,"bbox":[42,670,520,22]} +{"kind":"textbox","id":"sh/vqp8nq50","slide":5,"name":"footer-page-5","text":"05","textPreview":"05","textChars":2,"textLines":1,"bbox":[1184,670,54,22]} \ No newline at end of file diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-01.layout.json b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-01.layout.json new file mode 100644 index 0000000..d301257 --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-01.layout.json @@ -0,0 +1,521 @@ +{ + "schema": "openai.presentation.layout/v4", + "unit": "px", + "slide": { + "aid": "sl/h8vvsa", + "id": "h8vvsa", + "slide": 1, + "layoutId": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "layoutName": "Title Slide", + "layoutType": "title", + "masterLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "masterLayoutName": "Master", + "backgroundColor": "#FFFFFF", + "frame": { + "left": 0, + "top": 0, + "width": 1280, + "height": 720 + } + }, + "theme": { + "colorSchemeName": "ChatGPT", + "colors": { + "accent1": "#156082", + "accent2": "#E97132", + "accent3": "#196B24", + "accent4": "#0F9ED5", + "accent5": "#A02B93", + "accent6": "#4EA72E", + "bg1": "#FFFFFF", + "bg2": "#000000", + "tx1": "#1F1F1F", + "tx2": "#FFFFFF", + "dk1": "#000000", + "lt1": "#FFFFFF", + "dk2": "#0E2841", + "lt2": "#E8E8E8", + "hlink": "#467886", + "folHlink": "#96607D" + }, + "typefaces": [ + "+mn-lt" + ] + }, + "inheritedLayers": [ + { + "scope": "layout", + "id": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "name": "Title Slide", + "type": "title", + "parentLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "elements": [] + }, + { + "scope": "master", + "id": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "name": "Master", + "type": "master", + "elements": [] + } + ], + "elements": [ + { + "order": 1, + "kind": "shape", + "scope": "slide", + "aid": "sh/qhwveh8b", + "id": "1", + "name": "cover-eyebrow", + "bbox": [ + 42, + 40, + 620, + 34 + ], + "geometry": "rect", + "text": "NODESLIDE / OPENUI PHASE 0", + "textPreview": "NODESLIDE / OPENUI PHASE 0", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "NODESLIDE / OPENUI PHASE 0" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "NODESLIDE / OPENUI PHASE 0", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "NODESLIDE / OPENUI PHASE 0", + "fontSize": 18, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 2, + "kind": "shape", + "scope": "slide", + "aid": "sh/dk7epwrm", + "id": "2", + "name": "cover-title", + "bbox": [ + 42, + 165, + 1010, + 305 + ], + "geometry": "rect", + "text": "OpenUI makes the visual\ndecision visible before\nthe slide changes", + "textPreview": "OpenUI makes the visual | decision visible before | the slide changes", + "resolvedFontSize": 72, + "resolvedTextStyle": { + "anchor": 3, + "fontSize": 72, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "bottom", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 3, + "lines": [ + { + "index": 1, + "text": "OpenUI makes the visual" + }, + { + "index": 2, + "text": "decision visible before" + }, + { + "index": 3, + "text": "the slide changes" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "OpenUI makes the visual", + "resolvedTextStyle": { + "fontSize": 72, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "OpenUI makes the visual", + "fontSize": 72, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "decision visible before", + "resolvedTextStyle": { + "fontSize": 72, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "decision visible before", + "fontSize": 72, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 3, + "text": "the slide changes", + "resolvedTextStyle": { + "fontSize": 72, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "the slide changes", + "fontSize": 72, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 3, + "kind": "shape", + "scope": "slide", + "aid": "sh/cjedgrq1", + "id": "3", + "name": "cover-subtitle", + "bbox": [ + 42, + 520, + 840, + 92 + ], + "geometry": "rect", + "text": "A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.", + "textPreview": "A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.", + "resolvedFontSize": 28, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 28, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "A bounded adoption proof: structured visual materials → validated" + }, + { + "index": 2, + "text": "SlideLang proposal → human acceptance." + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.", + "resolvedTextStyle": { + "fontSize": 28, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "A bounded adoption proof: structured visual materials → validated SlideLang proposal → human acceptance.", + "fontSize": 28, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 4, + "kind": "shape", + "scope": "slide", + "aid": "sh/3e5wjm9k", + "id": "4", + "bbox": [ + 1075, + 40, + 163, + 20 + ], + "geometry": "rect", + "fillColor": "#6DCBF4", + "lineWidth": 0 + }, + { + "order": 5, + "kind": "shape", + "scope": "slide", + "aid": "sh/idwvah8z", + "id": "5", + "bbox": [ + 1062, + 72, + 176, + 24 + ], + "geometry": "rect", + "text": "LIVE LOCAL RUN", + "textPreview": "LIVE LOCAL RUN", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#6E747C", + "bold": true, + "alignment": "right", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "LIVE LOCAL RUN" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "LIVE LOCAL RUN", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#6E747C", + "bold": true, + "alignment": "right" + }, + "runs": [ + { + "index": 1, + "text": "LIVE LOCAL RUN", + "fontSize": 13, + "color": "#6E747C", + "bold": true, + "alignment": "right" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 6, + "kind": "shape", + "scope": "slide", + "aid": "sh/pgnelwrq", + "id": "6", + "name": "footer-label-1", + "bbox": [ + 42, + 670, + 520, + 22 + ], + "geometry": "rect", + "text": "LOCAL PROOF · 2026-07-16", + "textPreview": "LOCAL PROOF · 2026-07-16", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#6E747C", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 7, + "kind": "shape", + "scope": "slide", + "aid": "sh/4fedcrq5", + "id": "7", + "name": "footer-page-1", + "bbox": [ + 1184, + 670, + 54, + 22 + ], + "geometry": "rect", + "text": "01", + "textPreview": "01", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#000000", + "alignment": "right", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "01" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "01", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#000000", + "alignment": "right" + }, + "runs": [ + { + "index": 1, + "text": "01", + "fontSize": 13, + "color": "#000000", + "alignment": "right" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + } + ], + "composeRuns": [] +} \ No newline at end of file diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-01.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-01.png new file mode 100644 index 0000000..0f312f4 Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-01.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-02.layout.json b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-02.layout.json new file mode 100644 index 0000000..fe2f4ab --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-02.layout.json @@ -0,0 +1,1849 @@ +{ + "schema": "openai.presentation.layout/v4", + "unit": "px", + "slide": { + "aid": "sl/eye12a", + "id": "eye12a", + "slide": 2, + "layoutId": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "layoutName": "Title Slide", + "layoutType": "title", + "masterLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "masterLayoutName": "Master", + "backgroundColor": "#FFFFFF", + "frame": { + "left": 0, + "top": 0, + "width": 1280, + "height": 720 + } + }, + "theme": { + "colorSchemeName": "ChatGPT", + "colors": { + "accent1": "#156082", + "accent2": "#E97132", + "accent3": "#196B24", + "accent4": "#0F9ED5", + "accent5": "#A02B93", + "accent6": "#4EA72E", + "bg1": "#FFFFFF", + "bg2": "#000000", + "tx1": "#1F1F1F", + "tx2": "#FFFFFF", + "dk1": "#000000", + "lt1": "#FFFFFF", + "dk2": "#0E2841", + "lt2": "#E8E8E8", + "hlink": "#467886", + "folHlink": "#96607D" + }, + "typefaces": [ + "+mn-lt" + ] + }, + "inheritedLayers": [ + { + "scope": "layout", + "id": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "name": "Title Slide", + "type": "title", + "parentLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "elements": [] + }, + { + "scope": "master", + "id": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "name": "Master", + "type": "master", + "elements": [] + } + ], + "elements": [ + { + "order": 1, + "kind": "shape", + "scope": "slide", + "aid": "sh/don6t4je", + "id": "1", + "name": "slide-title-2", + "bbox": [ + 42, + 36, + 1196, + 92 + ], + "geometry": "rect", + "text": "Four incompatible units need a transformation ladder—not one axis", + "textPreview": "Four incompatible units need a transformation ladder—not one axis", + "resolvedFontSize": 42, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 42, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Four incompatible units need a transformation ladder—not one" + }, + { + "index": 2, + "text": "axis" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Four incompatible units need a transformation ladder—not one axis", + "resolvedTextStyle": { + "fontSize": 42, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Four incompatible units need a transformation ladder—not one axis", + "fontSize": 42, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 2, + "kind": "shape", + "scope": "slide", + "aid": "sh/qlcnipkn", + "id": "2", + "name": "footer-label-2", + "bbox": [ + 42, + 670, + 520, + 22 + ], + "geometry": "rect", + "text": "LOCAL PROOF · 2026-07-16", + "textPreview": "LOCAL PROOF · 2026-07-16", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#6E747C", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 3, + "kind": "shape", + "scope": "slide", + "aid": "sh/rmloru18", + "id": "3", + "name": "footer-page-2", + "bbox": [ + 1184, + 670, + 54, + 22 + ], + "geometry": "rect", + "text": "02", + "textPreview": "02", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#000000", + "alignment": "right", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "02" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "02", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#000000", + "alignment": "right" + }, + "runs": [ + { + "index": 1, + "text": "02", + "fontSize": 13, + "color": "#000000", + "alignment": "right" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 4, + "kind": "shape", + "scope": "slide", + "aid": "sh/4ju5gf2x", + "id": "4", + "bbox": [ + 42, + 125, + 1000, + 60 + ], + "geometry": "rect", + "text": "The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.", + "textPreview": "The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.", + "resolvedFontSize": 22, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 22, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit." + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.", + "resolvedTextStyle": { + "fontSize": 22, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "The OpenUI component keeps every scenario claim in its own unit and makes the non-chart decision explicit.", + "fontSize": 22, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 5, + "kind": "shape", + "scope": "slide", + "aid": "sh/pk3mpkj2", + "id": "5", + "bbox": [ + 42, + 247, + 262, + 285 + ], + "geometry": "rect", + "fillColor": "#EDEDED", + "lineColor": "#B8BCC4", + "lineWidth": 1 + }, + { + "order": 6, + "kind": "shape", + "scope": "slide", + "aid": "sh/2hcne5kr", + "id": "6", + "bbox": [ + 62, + 268, + 40, + 24 + ], + "geometry": "rect", + "text": "01", + "textPreview": "01", + "resolvedFontSize": 15, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 15, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "01" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "01", + "resolvedTextStyle": { + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "01", + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 7, + "kind": "shape", + "scope": "slide", + "aid": "sh/3il4na1c", + "id": "7", + "bbox": [ + 100, + 268, + 182, + 38 + ], + "geometry": "rect", + "text": "REPLICATION", + "textPreview": "REPLICATION", + "resolvedFontSize": 14, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 14, + "typeface": "+mn-lt", + "color": "#6E747C", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "REPLICATION" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "REPLICATION", + "resolvedTextStyle": { + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "REPLICATION", + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 8, + "kind": "shape", + "scope": "slide", + "aid": "sh/ofy5sj21", + "id": "8", + "bbox": [ + 62, + 343, + 218, + 88 + ], + "geometry": "rect", + "text": "200K copies", + "textPreview": "200K copies", + "resolvedFontSize": 30, + "resolvedTextStyle": { + "anchor": 2, + "fontSize": 30, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "middle", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "200K copies" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "200K copies", + "resolvedTextStyle": { + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "200K copies", + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 9, + "kind": "shape", + "scope": "slide", + "aid": "sh/9g761ojm", + "id": "9", + "bbox": [ + 62, + 451, + 218, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#B8BCC4", + "lineWidth": 1 + }, + { + "order": 10, + "kind": "shape", + "scope": "slide", + "aid": "sh/lkn2d0bq", + "id": "10", + "bbox": [ + 62, + 469, + 218, + 42 + ], + "geometry": "rect", + "text": "copies", + "textPreview": "copies", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "copies" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "copies", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "copies", + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 11, + "kind": "shape", + "scope": "slide", + "aid": "sh/kjelkfa5", + "id": "11", + "bbox": [ + 304, + 365, + 24, + 40 + ], + "geometry": "rect", + "text": "→", + "textPreview": "→", + "resolvedFontSize": 24, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 24, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "alignment": "center", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "→" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "→", + "resolvedTextStyle": { + "fontSize": 24, + "color": "#3D8DFF", + "alignment": "center" + }, + "runs": [ + { + "index": 1, + "text": "→", + "fontSize": 24, + "color": "#3D8DFF", + "alignment": "center" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 12, + "kind": "shape", + "scope": "slide", + "aid": "sh/zi5kbatk", + "id": "12", + "bbox": [ + 328, + 247, + 262, + 285 + ], + "geometry": "rect", + "fillColor": "#EDEDED", + "lineColor": "#B8BCC4", + "lineWidth": 1 + }, + { + "order": 13, + "kind": "shape", + "scope": "slide", + "aid": "sh/yhw3i5sz", + "id": "13", + "bbox": [ + 348, + 268, + 40, + 24 + ], + "geometry": "rect", + "text": "02", + "textPreview": "02", + "resolvedFontSize": 15, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 15, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "02" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "02", + "resolvedTextStyle": { + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "02", + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 14, + "kind": "shape", + "scope": "slide", + "aid": "sh/xg3290be", + "id": "14", + "bbox": [ + 386, + 268, + 182, + 38 + ], + "geometry": "rect", + "text": "EQUIVALENT CAPACITY", + "textPreview": "EQUIVALENT CAPACITY", + "resolvedFontSize": 14, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 14, + "typeface": "+mn-lt", + "color": "#6E747C", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "EQUIVALENT CAPACITY" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "EQUIVALENT CAPACITY", + "resolvedTextStyle": { + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "EQUIVALENT CAPACITY", + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 15, + "kind": "shape", + "scope": "slide", + "aid": "sh/cfulgvat", + "id": "15", + "bbox": [ + 348, + 343, + 218, + 88 + ], + "geometry": "rect", + "text": "50K coder\nequivalents", + "textPreview": "50K coder | equivalents", + "resolvedFontSize": 30, + "resolvedTextStyle": { + "anchor": 2, + "fontSize": 30, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "middle", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "50K coder" + }, + { + "index": 2, + "text": "equivalents" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "50K coder", + "resolvedTextStyle": { + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "50K coder", + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "equivalents", + "resolvedTextStyle": { + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "equivalents", + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 16, + "kind": "shape", + "scope": "slide", + "aid": "sh/belk7qt8", + "id": "16", + "bbox": [ + 348, + 451, + 218, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#B8BCC4", + "lineWidth": 1 + }, + { + "order": 17, + "kind": "shape", + "scope": "slide", + "aid": "sh/adc3els3", + "id": "17", + "bbox": [ + 348, + 469, + 218, + 42 + ], + "geometry": "rect", + "text": "coder equivalents", + "textPreview": "coder equivalents", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "coder equivalents" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "coder equivalents", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "coder equivalents", + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 18, + "kind": "shape", + "scope": "slide", + "aid": "sh/hc7mlkry", + "id": "18", + "bbox": [ + 590, + 365, + 24, + 40 + ], + "geometry": "rect", + "text": "→", + "textPreview": "→", + "resolvedFontSize": 24, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 24, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "alignment": "center", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "→" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "→", + "resolvedTextStyle": { + "fontSize": 24, + "color": "#3D8DFF", + "alignment": "center" + }, + "runs": [ + { + "index": 1, + "text": "→", + "fontSize": 24, + "color": "#3D8DFF", + "alignment": "center" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 19, + "kind": "shape", + "scope": "slide", + "aid": "sh/wbylszad", + "id": "19", + "bbox": [ + 614, + 247, + 262, + 285 + ], + "geometry": "rect", + "fillColor": "#EDEDED", + "lineColor": "#B8BCC4", + "lineWidth": 1 + }, + { + "order": 20, + "kind": "shape", + "scope": "slide", + "aid": "sh/gf65o3i9", + "id": "20", + "bbox": [ + 634, + 268, + 40, + 24 + ], + "geometry": "rect", + "text": "03", + "textPreview": "03", + "resolvedFontSize": 15, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 15, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "03" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "03", + "resolvedTextStyle": { + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "03", + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 21, + "kind": "shape", + "scope": "slide", + "aid": "sh/1gf6h8zu", + "id": "21", + "bbox": [ + 672, + 268, + 182, + 38 + ], + "geometry": "rect", + "text": "INDIVIDUAL ACCELERATION", + "textPreview": "INDIVIDUAL ACCELERATION", + "resolvedFontSize": 14, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 14, + "typeface": "+mn-lt", + "color": "#6E747C", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "INDIVIDUAL ACCELERATION" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "INDIVIDUAL ACCELERATION", + "resolvedTextStyle": { + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "INDIVIDUAL ACCELERATION", + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 22, + "kind": "shape", + "scope": "slide", + "aid": "sh/etonmt0j", + "id": "22", + "bbox": [ + 634, + 343, + 218, + 88 + ], + "geometry": "rect", + "text": "30× speed", + "textPreview": "30× speed", + "resolvedFontSize": 30, + "resolvedTextStyle": { + "anchor": 2, + "fontSize": 30, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "middle", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "30× speed" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "30× speed", + "resolvedTextStyle": { + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "30× speed", + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 23, + "kind": "shape", + "scope": "slide", + "aid": "sh/fux4fyho", + "id": "23", + "bbox": [ + 634, + 451, + 218, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#B8BCC4", + "lineWidth": 1 + }, + { + "order": 24, + "kind": "shape", + "scope": "slide", + "aid": "sh/srm5kjid", + "id": "24", + "bbox": [ + 634, + 469, + 218, + 42 + ], + "geometry": "rect", + "text": "individual speed multiplier", + "textPreview": "individual speed multiplier", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "individual speed multiplier" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "individual speed multiplier", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "individual speed multiplier", + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 25, + "kind": "shape", + "scope": "slide", + "aid": "sh/tsfmdozy", + "id": "25", + "bbox": [ + 876, + 365, + 24, + 40 + ], + "geometry": "rect", + "text": "→", + "textPreview": "→", + "resolvedFontSize": 24, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 24, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "alignment": "center", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "→" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "→", + "resolvedTextStyle": { + "fontSize": 24, + "color": "#3D8DFF", + "alignment": "center" + }, + "runs": [ + { + "index": 1, + "text": "→", + "fontSize": 24, + "color": "#3D8DFF", + "alignment": "center" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 26, + "kind": "shape", + "scope": "slide", + "aid": "sh/6p4nit07", + "id": "26", + "bbox": [ + 900, + 247, + 262, + 285 + ], + "geometry": "rect", + "fillColor": "#D0EDFA", + "lineColor": "#3D8DFF", + "lineWidth": 2 + }, + { + "order": 27, + "kind": "shape", + "scope": "slide", + "aid": "sh/rqd4behs", + "id": "27", + "bbox": [ + 920, + 268, + 40, + 24 + ], + "geometry": "rect", + "text": "04", + "textPreview": "04", + "resolvedFontSize": 15, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 15, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "04" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "04", + "resolvedTextStyle": { + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "04", + "fontSize": 15, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 28, + "kind": "shape", + "scope": "slide", + "aid": "sh/knm5gji1", + "id": "28", + "bbox": [ + 958, + 268, + 182, + 38 + ], + "geometry": "rect", + "text": "SYSTEM OUTCOME", + "textPreview": "SYSTEM OUTCOME", + "resolvedFontSize": 14, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 14, + "typeface": "+mn-lt", + "color": "#6E747C", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "SYSTEM OUTCOME" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "SYSTEM OUTCOME", + "resolvedTextStyle": { + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "SYSTEM OUTCOME", + "fontSize": 14, + "color": "#6E747C", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 29, + "kind": "shape", + "scope": "slide", + "aid": "sh/5ovm9ozm", + "id": "29", + "bbox": [ + 920, + 343, + 218, + 88 + ], + "geometry": "rect", + "text": "4× research\nprogress", + "textPreview": "4× research | progress", + "resolvedFontSize": 30, + "resolvedTextStyle": { + "anchor": 2, + "fontSize": 30, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "middle", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "4× research" + }, + { + "index": 2, + "text": "progress" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "4× research", + "resolvedTextStyle": { + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "4× research", + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "progress", + "resolvedTextStyle": { + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "progress", + "fontSize": 30, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 30, + "kind": "shape", + "scope": "slide", + "aid": "sh/nitozelo", + "id": "30", + "bbox": [ + 920, + 451, + 218, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#3D8DFF", + "lineWidth": 1 + }, + { + "order": 31, + "kind": "shape", + "scope": "slide", + "aid": "sh/mhknq9k3", + "id": "31", + "bbox": [ + 920, + 469, + 218, + 42 + ], + "geometry": "rect", + "text": "overall progress multiplier", + "textPreview": "overall progress multiplier", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "overall progress multiplier" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "overall progress multiplier", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "overall progress multiplier", + "fontSize": 16, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 32, + "kind": "shape", + "scope": "slide", + "aid": "sh/9kb61o3u", + "id": "32", + "bbox": [ + 42, + 574, + 560, + 30 + ], + "geometry": "rect", + "text": "USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED", + "textPreview": "USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED", + "resolvedFontSize": 15, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 15, + "typeface": "+mn-lt", + "color": "#D97757", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED", + "resolvedTextStyle": { + "fontSize": 15, + "color": "#D97757", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "USER-PROVIDED SCENARIO FIXTURE · UNVERIFIED", + "fontSize": 15, + "color": "#D97757", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 33, + "kind": "shape", + "scope": "slide", + "aid": "sh/8j25sj29", + "id": "33", + "bbox": [ + 830, + 574, + 408, + 30 + ], + "geometry": "rect", + "text": "NO SHARED AXIS · MIXED UNITS", + "textPreview": "NO SHARED AXIS · MIXED UNITS", + "resolvedFontSize": 15, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 15, + "typeface": "+mn-lt", + "color": "#D97757", + "bold": true, + "alignment": "right", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "NO SHARED AXIS · MIXED UNITS" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "NO SHARED AXIS · MIXED UNITS", + "resolvedTextStyle": { + "fontSize": 15, + "color": "#D97757", + "bold": true, + "alignment": "right" + }, + "runs": [ + { + "index": 1, + "text": "NO SHARED AXIS · MIXED UNITS", + "fontSize": 15, + "color": "#D97757", + "bold": true, + "alignment": "right" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + } + ], + "composeRuns": [] +} \ No newline at end of file diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-02.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-02.png new file mode 100644 index 0000000..880b482 Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-02.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-03.layout.json b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-03.layout.json new file mode 100644 index 0000000..0a065b2 --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-03.layout.json @@ -0,0 +1,1340 @@ +{ + "schema": "openai.presentation.layout/v4", + "unit": "px", + "slide": { + "aid": "sl/251tu4", + "id": "251tu4", + "slide": 3, + "layoutId": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "layoutName": "Title Slide", + "layoutType": "title", + "masterLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "masterLayoutName": "Master", + "backgroundColor": "#FFFFFF", + "frame": { + "left": 0, + "top": 0, + "width": 1280, + "height": 720 + } + }, + "theme": { + "colorSchemeName": "ChatGPT", + "colors": { + "accent1": "#156082", + "accent2": "#E97132", + "accent3": "#196B24", + "accent4": "#0F9ED5", + "accent5": "#A02B93", + "accent6": "#4EA72E", + "bg1": "#FFFFFF", + "bg2": "#000000", + "tx1": "#1F1F1F", + "tx2": "#FFFFFF", + "dk1": "#000000", + "lt1": "#FFFFFF", + "dk2": "#0E2841", + "lt2": "#E8E8E8", + "hlink": "#467886", + "folHlink": "#96607D" + }, + "typefaces": [ + "+mn-lt" + ] + }, + "inheritedLayers": [ + { + "scope": "layout", + "id": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "name": "Title Slide", + "type": "title", + "parentLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "elements": [] + }, + { + "scope": "master", + "id": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "name": "Master", + "type": "master", + "elements": [] + } + ], + "elements": [ + { + "order": 1, + "kind": "shape", + "scope": "slide", + "aid": "sh/dwz29036", + "id": "1", + "name": "slide-title-3", + "bbox": [ + 42, + 36, + 1196, + 92 + ], + "geometry": "rect", + "text": "Selection crosses one governed boundary at a time", + "textPreview": "Selection crosses one governed boundary at a time", + "resolvedFontSize": 42, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 42, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "Selection crosses one governed boundary at a time" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Selection crosses one governed boundary at a time", + "resolvedTextStyle": { + "fontSize": 42, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Selection crosses one governed boundary at a time", + "fontSize": 42, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 2, + "kind": "shape", + "scope": "slide", + "aid": "sh/qto3y5kf", + "id": "2", + "name": "footer-label-3", + "bbox": [ + 42, + 670, + 520, + 22 + ], + "geometry": "rect", + "text": "LOCAL PROOF · 2026-07-16", + "textPreview": "LOCAL PROOF · 2026-07-16", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#6E747C", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 3, + "kind": "shape", + "scope": "slide", + "aid": "sh/ruxk7ql0", + "id": "3", + "name": "footer-page-3", + "bbox": [ + 1184, + 670, + 54, + 22 + ], + "geometry": "rect", + "text": "03", + "textPreview": "03", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#000000", + "alignment": "right", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "03" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "03", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#000000", + "alignment": "right" + }, + "runs": [ + { + "index": 1, + "text": "03", + "fontSize": 13, + "color": "#000000", + "alignment": "right" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 4, + "kind": "shape", + "scope": "slide", + "aid": "sh/0zql4f2h", + "id": "4", + "bbox": [ + 42, + 126, + 1020, + 55 + ], + "geometry": "rect", + "text": "OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.", + "textPreview": "OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.", + "resolvedFontSize": 22, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 22, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers." + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.", + "resolvedTextStyle": { + "fontSize": 22, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "OpenUI is the decision surface. SlideLang and Convex remain the document and authority layers.", + "fontSize": 22, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 5, + "kind": "shape", + "scope": "slide", + "aid": "sh/10zmdk32", + "id": "5", + "bbox": [ + 78, + 355, + 1092, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#000000", + "lineWidth": 1 + }, + { + "order": 6, + "kind": "shape", + "scope": "slide", + "aid": "sh/ex8325kr", + "id": "6", + "bbox": [ + 78, + 346, + 18, + 18 + ], + "geometry": "ellipse", + "fillColor": "#000000", + "lineWidth": 0 + }, + { + "order": 7, + "kind": "shape", + "scope": "slide", + "aid": "sh/zyh4balc", + "id": "7", + "bbox": [ + 78, + 294, + 44, + 28 + ], + "geometry": "rect", + "text": "01", + "textPreview": "01", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "01" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "01", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "01", + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 8, + "kind": "shape", + "scope": "slide", + "aid": "sh/onal8f2t", + "id": "8", + "bbox": [ + 78, + 390, + 245, + 34 + ], + "geometry": "rect", + "text": "OPENUI LANG", + "textPreview": "OPENUI LANG", + "resolvedFontSize": 20, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 20, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "OPENUI LANG" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "OPENUI LANG", + "resolvedTextStyle": { + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "OPENUI LANG", + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 9, + "kind": "shape", + "scope": "slide", + "aid": "sh/9ojmhkje", + "id": "9", + "bbox": [ + 78, + 436, + 250, + 84 + ], + "geometry": "rect", + "text": "Render an allowlisted\nvisual decision", + "textPreview": "Render an allowlisted | visual decision", + "resolvedFontSize": 19, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 19, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Render an allowlisted" + }, + { + "index": 2, + "text": "visual decision" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Render an allowlisted", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Render an allowlisted", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "visual decision", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "visual decision", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 10, + "kind": "shape", + "scope": "slide", + "aid": "sh/5g3etgne", + "id": "10", + "bbox": [ + 370, + 346, + 18, + 18 + ], + "geometry": "ellipse", + "fillColor": "#000000", + "lineWidth": 0 + }, + { + "order": 11, + "kind": "shape", + "scope": "slide", + "aid": "sh/4fux0b6t", + "id": "11", + "bbox": [ + 370, + 294, + 44, + 28 + ], + "geometry": "rect", + "text": "02", + "textPreview": "02", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "02" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "02", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "02", + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 12, + "kind": "shape", + "scope": "slide", + "aid": "sh/3elwr658", + "id": "12", + "bbox": [ + 370, + 390, + 245, + 34 + ], + "geometry": "rect", + "text": "VISUAL MATERIAL SPEC", + "textPreview": "VISUAL MATERIAL SPEC", + "resolvedFontSize": 20, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 20, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "VISUAL MATERIAL SPEC" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "VISUAL MATERIAL SPEC", + "resolvedTextStyle": { + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "VISUAL MATERIAL SPEC", + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 13, + "kind": "shape", + "scope": "slide", + "aid": "sh/itcfy1on", + "id": "13", + "bbox": [ + 370, + 436, + 250, + 84 + ], + "geometry": "rect", + "text": "Validate units, labels,\nand provenance", + "textPreview": "Validate units, labels, | and provenance", + "resolvedFontSize": 19, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 19, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Validate units, labels," + }, + { + "index": 2, + "text": "and provenance" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Validate units, labels,", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Validate units, labels,", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "and provenance", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "and provenance", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 14, + "kind": "shape", + "scope": "slide", + "aid": "sh/dknexgna", + "id": "14", + "bbox": [ + 662, + 346, + 18, + 18 + ], + "geometry": "ellipse", + "fillColor": "#000000", + "lineWidth": 0 + }, + { + "order": 15, + "kind": "shape", + "scope": "slide", + "aid": "sh/sjex4b6p", + "id": "15", + "bbox": [ + 662, + 294, + 44, + 28 + ], + "geometry": "rect", + "text": "03", + "textPreview": "03", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "03" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "03", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "03", + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 16, + "kind": "shape", + "scope": "slide", + "aid": "sh/rilwvq5k", + "id": "16", + "bbox": [ + 662, + 390, + 245, + 34 + ], + "geometry": "rect", + "text": "SLIDELANG PROPOSAL", + "textPreview": "SLIDELANG PROPOSAL", + "resolvedFontSize": 20, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 20, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "SLIDELANG PROPOSAL" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "SLIDELANG PROPOSAL", + "resolvedTextStyle": { + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "SLIDELANG PROPOSAL", + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 17, + "kind": "shape", + "scope": "slide", + "aid": "sh/qhcf2loz", + "id": "17", + "bbox": [ + 662, + 436, + 250, + 84 + ], + "geometry": "rect", + "text": "Compile one editable\nadd-slide operation", + "textPreview": "Compile one editable | add-slide operation", + "resolvedFontSize": 19, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 19, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Compile one editable" + }, + { + "index": 2, + "text": "add-slide operation" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Compile one editable", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Compile one editable", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "add-slide operation", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "add-slide operation", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 18, + "kind": "shape", + "scope": "slide", + "aid": "sh/toje1wn6", + "id": "18", + "bbox": [ + 954, + 346, + 18, + 18 + ], + "geometry": "ellipse", + "fillColor": "#3D8DFF", + "lineWidth": 0 + }, + { + "order": 19, + "kind": "shape", + "scope": "slide", + "aid": "sh/8nadsr6l", + "id": "19", + "bbox": [ + 954, + 294, + 44, + 28 + ], + "geometry": "rect", + "text": "04", + "textPreview": "04", + "resolvedFontSize": 16, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 16, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "04" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "04", + "resolvedTextStyle": { + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "04", + "fontSize": 16, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 20, + "kind": "shape", + "scope": "slide", + "aid": "sh/kjy54vit", + "id": "20", + "bbox": [ + 954, + 390, + 245, + 34 + ], + "geometry": "rect", + "text": "HUMAN ACCEPT", + "textPreview": "HUMAN ACCEPT", + "resolvedFontSize": 20, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 20, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "HUMAN ACCEPT" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "HUMAN ACCEPT", + "resolvedTextStyle": { + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "HUMAN ACCEPT", + "fontSize": 20, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 21, + "kind": "shape", + "scope": "slide", + "aid": "sh/lk76xgze", + "id": "21", + "bbox": [ + 954, + 436, + 250, + 84 + ], + "geometry": "rect", + "text": "CAS + candidate receipt\nadvance v1 → v2", + "textPreview": "CAS + candidate receipt | advance v1 → v2", + "resolvedFontSize": 19, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 19, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "CAS + candidate receipt" + }, + { + "index": 2, + "text": "advance v1 → v2" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "CAS + candidate receipt", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "CAS + candidate receipt", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "advance v1 → v2", + "resolvedTextStyle": { + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "advance v1 → v2", + "fontSize": 19, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 22, + "kind": "shape", + "scope": "slide", + "aid": "sh/yhwn2l0n", + "id": "22", + "bbox": [ + 42, + 575, + 1196, + 55 + ], + "geometry": "rect", + "fillColor": "#EDEDED", + "lineWidth": 0 + }, + { + "order": 23, + "kind": "shape", + "scope": "slide", + "aid": "sh/ji5ovqh8", + "id": "23", + "bbox": [ + 65, + 592, + 1150, + 28 + ], + "geometry": "rect", + "text": "Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id", + "textPreview": "Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#D97757", + "bold": true, + "alignment": "center", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#D97757", + "bold": true, + "alignment": "center" + }, + "runs": [ + { + "index": 1, + "text": "Fail closed: mixed-unit chart · fabricated source · stale deck version · duplicate slide id", + "fontSize": 18, + "color": "#D97757", + "bold": true, + "alignment": "center" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + } + ], + "composeRuns": [] +} \ No newline at end of file diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-03.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-03.png new file mode 100644 index 0000000..2bd1441 Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-03.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-04.layout.json b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-04.layout.json new file mode 100644 index 0000000..eef65d8 --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-04.layout.json @@ -0,0 +1,796 @@ +{ + "schema": "openai.presentation.layout/v4", + "unit": "px", + "slide": { + "aid": "sl/rbc51e", + "id": "rbc51e", + "slide": 4, + "layoutId": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "layoutName": "Title Slide", + "layoutType": "title", + "masterLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "masterLayoutName": "Master", + "backgroundColor": "#FFFFFF", + "frame": { + "left": 0, + "top": 0, + "width": 1280, + "height": 720 + } + }, + "theme": { + "colorSchemeName": "ChatGPT", + "colors": { + "accent1": "#156082", + "accent2": "#E97132", + "accent3": "#196B24", + "accent4": "#0F9ED5", + "accent5": "#A02B93", + "accent6": "#4EA72E", + "bg1": "#FFFFFF", + "bg2": "#000000", + "tx1": "#1F1F1F", + "tx2": "#FFFFFF", + "dk1": "#000000", + "lt1": "#FFFFFF", + "dk2": "#0E2841", + "lt2": "#E8E8E8", + "hlink": "#467886", + "folHlink": "#96607D" + }, + "typefaces": [ + "+mn-lt" + ] + }, + "inheritedLayers": [ + { + "scope": "layout", + "id": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "name": "Title Slide", + "type": "title", + "parentLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "elements": [] + }, + { + "scope": "master", + "id": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "name": "Master", + "type": "master", + "elements": [] + } + ], + "elements": [ + { + "order": 1, + "kind": "shape", + "scope": "slide", + "aid": "sh/8z6p87al", + "id": "1", + "name": "slide-title-4", + "bbox": [ + 42, + 36, + 1196, + 92 + ], + "geometry": "rect", + "text": "The live interaction proved the boundary—not just the render", + "textPreview": "The live interaction proved the boundary—not just the render", + "resolvedFontSize": 42, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 42, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "The live interaction proved the boundary—not just the render" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "The live interaction proved the boundary—not just the render", + "resolvedTextStyle": { + "fontSize": 42, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "The live interaction proved the boundary—not just the render", + "fontSize": 42, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 2, + "kind": "shape", + "scope": "slide", + "aid": "sh/nyxozm90", + "id": "2", + "name": "footer-label-4", + "bbox": [ + 42, + 670, + 520, + 22 + ], + "geometry": "rect", + "text": "LOCAL PROOF · 2026-07-16", + "textPreview": "LOCAL PROOF · 2026-07-16", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#6E747C", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "LOCAL PROOF · 2026-07-16", + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 3, + "kind": "shape", + "scope": "slide", + "aid": "sh/mxo76hsf", + "id": "3", + "name": "footer-page-4", + "bbox": [ + 1184, + 670, + 54, + 22 + ], + "geometry": "rect", + "text": "04", + "textPreview": "04", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#000000", + "alignment": "right", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "04" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "04", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#000000", + "alignment": "right" + }, + "runs": [ + { + "index": 1, + "text": "04", + "fontSize": 13, + "color": "#000000", + "alignment": "right" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 4, + "kind": "shape", + "scope": "slide", + "aid": "sh/lwv6xcru", + "id": "4", + "bbox": [ + 42, + 126, + 790, + 45 + ], + "geometry": "rect", + "text": "Disposable local workspace · exact candidate preview corrected during dogfood", + "textPreview": "Disposable local workspace · exact candidate preview corrected during dogfood", + "resolvedFontSize": 20, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 20, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "Disposable local workspace · exact candidate preview corrected during dogfood" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Disposable local workspace · exact candidate preview corrected during dogfood", + "resolvedTextStyle": { + "fontSize": 20, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Disposable local workspace · exact candidate preview corrected during dogfood", + "fontSize": 20, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 5, + "kind": "image", + "scope": "slide", + "aid": "im/1kneh43i", + "id": "5", + "bbox": [ + 42, + 194, + 780, + 438 + ], + "alt": "NodeSlide Compare showing the baseline and the new OpenUI visual material proposal.", + "contentType": "image/jpeg", + "imageFit": "contain", + "asset": { + "assetId": "674d0311-bf48-484a-ab87-69aad8c63255", + "contentType": "image/jpeg", + "byteLength": 96808 + } + }, + { + "order": 6, + "kind": "shape", + "scope": "slide", + "aid": "sh/zadov29o", + "id": "6", + "bbox": [ + 858, + 194, + 380, + 128 + ], + "geometry": "rect", + "fillColor": "#EDEDED", + "lineWidth": 0 + }, + { + "order": 7, + "kind": "shape", + "scope": "slide", + "aid": "sh/e94n2xs3", + "id": "7", + "bbox": [ + 884, + 216, + 330, + 52 + ], + "geometry": "rect", + "text": "15 / 15", + "textPreview": "15 / 15", + "resolvedFontSize": 42, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 42, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "15 / 15" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "15 / 15", + "resolvedTextStyle": { + "fontSize": 42, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "15 / 15", + "fontSize": 42, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 8, + "kind": "shape", + "scope": "slide", + "aid": "sh/5sz69wry", + "id": "8", + "bbox": [ + 884, + 274, + 330, + 36 + ], + "geometry": "rect", + "text": "OpenUI + agent contract tests passed", + "textPreview": "OpenUI + agent contract tests passed", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "OpenUI + agent contract tests passed" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "OpenUI + agent contract tests passed", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "OpenUI + agent contract tests passed", + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 9, + "kind": "shape", + "scope": "slide", + "aid": "sh/krqpgbat", + "id": "9", + "bbox": [ + 858, + 342, + 380, + 128 + ], + "geometry": "rect", + "fillColor": "#EDEDED", + "lineWidth": 0 + }, + { + "order": 10, + "kind": "shape", + "scope": "slide", + "aid": "sh/ahgr2dg7", + "id": "10", + "bbox": [ + 884, + 364, + 330, + 52 + ], + "geometry": "rect", + "text": "v1 → v2", + "textPreview": "v1 → v2", + "resolvedFontSize": 42, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 42, + "typeface": "+mn-lt", + "color": "#047857", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "v1 → v2" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "v1 → v2", + "resolvedTextStyle": { + "fontSize": 42, + "color": "#047857", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "v1 → v2", + "fontSize": 42, + "color": "#047857", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 11, + "kind": "shape", + "scope": "slide", + "aid": "sh/bip8bixs", + "id": "11", + "bbox": [ + 884, + 422, + 330, + 40 + ], + "geometry": "rect", + "text": "Pending proposal stayed unapplied until Accept", + "textPreview": "Pending proposal stayed unapplied until Accept", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Pending proposal stayed unapplied until" + }, + { + "index": 2, + "text": "Accept" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Pending proposal stayed unapplied until Accept", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Pending proposal stayed unapplied until Accept", + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 12, + "kind": "shape", + "scope": "slide", + "aid": "sh/wjy94nyd", + "id": "12", + "bbox": [ + 858, + 490, + 380, + 142 + ], + "geometry": "rect", + "fillColor": "#D0EDFA", + "lineWidth": 0 + }, + { + "order": 13, + "kind": "shape", + "scope": "slide", + "aid": "sh/xk7qdsfy", + "id": "13", + "bbox": [ + 884, + 512, + 330, + 46 + ], + "geometry": "rect", + "text": "1 operation", + "textPreview": "1 operation", + "resolvedFontSize": 34, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 34, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "1 operation" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "1 operation", + "resolvedTextStyle": { + "fontSize": 34, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "1 operation", + "fontSize": 34, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 14, + "kind": "shape", + "scope": "slide", + "aid": "sh/mdwrydgb", + "id": "14", + "bbox": [ + 884, + 566, + 330, + 58 + ], + "geometry": "rect", + "text": "Editable add-slide patch\nwith a candidate validation receipt", + "textPreview": "Editable add-slide patch | with a candidate validation receipt", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Editable add-slide patch" + }, + { + "index": 2, + "text": "with a candidate validation receipt" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Editable add-slide patch", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Editable add-slide patch", + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "with a candidate validation receipt", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "with a candidate validation receipt", + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + } + ], + "composeRuns": [] +} \ No newline at end of file diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-04.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-04.png new file mode 100644 index 0000000..75af95c Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-04.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-05.layout.json b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-05.layout.json new file mode 100644 index 0000000..8d00c6a --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-05.layout.json @@ -0,0 +1,779 @@ +{ + "schema": "openai.presentation.layout/v4", + "unit": "px", + "slide": { + "aid": "sl/050391", + "id": "050391", + "slide": 5, + "layoutId": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "layoutName": "Title Slide", + "layoutType": "title", + "masterLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "masterLayoutName": "Master", + "backgroundColor": "#FFFFFF", + "frame": { + "left": 0, + "top": 0, + "width": 1280, + "height": 720 + } + }, + "theme": { + "colorSchemeName": "ChatGPT", + "colors": { + "accent1": "#156082", + "accent2": "#E97132", + "accent3": "#196B24", + "accent4": "#0F9ED5", + "accent5": "#A02B93", + "accent6": "#4EA72E", + "bg1": "#FFFFFF", + "bg2": "#000000", + "tx1": "#1F1F1F", + "tx2": "#FFFFFF", + "dk1": "#000000", + "lt1": "#FFFFFF", + "dk2": "#0E2841", + "lt2": "#E8E8E8", + "hlink": "#467886", + "folHlink": "#96607D" + }, + "typefaces": [ + "+mn-lt" + ] + }, + "inheritedLayers": [ + { + "scope": "layout", + "id": "4131ffe9-70f1-45b4-aa2b-16d898fa1bd8", + "name": "Title Slide", + "type": "title", + "parentLayoutId": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "elements": [] + }, + { + "scope": "master", + "id": "76b35871-2f82-400d-a95a-504c9bf0a8cd", + "name": "Master", + "type": "master", + "elements": [] + } + ], + "elements": [ + { + "order": 1, + "kind": "shape", + "scope": "slide", + "aid": "sh/yl0rylg3", + "id": "1", + "bbox": [ + 42, + 40, + 260, + 30 + ], + "geometry": "rect", + "text": "RELEASE BAR", + "textPreview": "RELEASE BAR", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#3D8DFF", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "RELEASE BAR" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "RELEASE BAR", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "RELEASE BAR", + "fontSize": 18, + "color": "#3D8DFF", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 2, + "kind": "shape", + "scope": "slide", + "aid": "sh/l8rq90fu", + "id": "2", + "bbox": [ + 42, + 178, + 950, + 245 + ], + "geometry": "rect", + "text": "Adopt Phase 0 now.\nKeep the boundary.", + "textPreview": "Adopt Phase 0 now. | Keep the boundary.", + "resolvedFontSize": 78, + "resolvedTextStyle": { + "anchor": 3, + "fontSize": 78, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "bottom", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Adopt Phase 0 now." + }, + { + "index": 2, + "text": "Keep the boundary." + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Adopt Phase 0 now.", + "resolvedTextStyle": { + "fontSize": 78, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Adopt Phase 0 now.", + "fontSize": 78, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + }, + { + "index": 2, + "text": "Keep the boundary.", + "resolvedTextStyle": { + "fontSize": 78, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Keep the boundary.", + "fontSize": 78, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 3, + "kind": "shape", + "scope": "slide", + "aid": "sh/kni90vy9", + "id": "3", + "bbox": [ + 42, + 492, + 350, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#B8BCC4", + "lineWidth": 2 + }, + { + "order": 4, + "kind": "shape", + "scope": "slide", + "aid": "sh/7a98bax0", + "id": "4", + "bbox": [ + 42, + 516, + 350, + 32 + ], + "geometry": "rect", + "text": "01 OPENUI", + "textPreview": "01 OPENUI", + "resolvedFontSize": 17, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 17, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "01 OPENUI" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "01 OPENUI", + "resolvedTextStyle": { + "fontSize": 17, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "01 OPENUI", + "fontSize": 17, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 5, + "kind": "shape", + "scope": "slide", + "aid": "sh/m90r25gf", + "id": "5", + "bbox": [ + 42, + 558, + 350, + 70 + ], + "geometry": "rect", + "text": "Bounded component library + visible provenance", + "textPreview": "Bounded component library + visible provenance", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 2, + "lines": [ + { + "index": 1, + "text": "Bounded component library + visible" + }, + { + "index": 2, + "text": "provenance" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Bounded component library + visible provenance", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Bounded component library + visible provenance", + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 6, + "kind": "shape", + "scope": "slide", + "aid": "sh/9crad0fq", + "id": "6", + "bbox": [ + 437, + 492, + 350, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#B8BCC4", + "lineWidth": 2 + }, + { + "order": 7, + "kind": "shape", + "scope": "slide", + "aid": "sh/8bi94fyl", + "id": "7", + "bbox": [ + 437, + 516, + 350, + 32 + ], + "geometry": "rect", + "text": "02 SLIDELANG", + "textPreview": "02 SLIDELANG", + "resolvedFontSize": 17, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 17, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "02 SLIDELANG" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "02 SLIDELANG", + "resolvedTextStyle": { + "fontSize": 17, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "02 SLIDELANG", + "fontSize": 17, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 8, + "kind": "shape", + "scope": "slide", + "aid": "sh/nep8z6xg", + "id": "8", + "bbox": [ + 437, + 558, + 350, + 70 + ], + "geometry": "rect", + "text": "Editable output + exact candidate validation", + "textPreview": "Editable output + exact candidate validation", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "Editable output + exact candidate validation" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "Editable output + exact candidate validation", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "Editable output + exact candidate validation", + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 9, + "kind": "shape", + "scope": "slide", + "aid": "sh/2dwrq1gv", + "id": "9", + "bbox": [ + 832, + 492, + 350, + 0 + ], + "geometry": "straightConnector1", + "lineColor": "#3D8DFF", + "lineWidth": 2 + }, + { + "order": 10, + "kind": "shape", + "scope": "slide", + "aid": "sh/wrypgv6l", + "id": "10", + "bbox": [ + 832, + 516, + 350, + 32 + ], + "geometry": "rect", + "text": "03 HUMAN REVIEW", + "textPreview": "03 HUMAN REVIEW", + "resolvedFontSize": 17, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 17, + "typeface": "+mn-lt", + "color": "#000000", + "bold": true, + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "03 HUMAN REVIEW" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "03 HUMAN REVIEW", + "resolvedTextStyle": { + "fontSize": 17, + "color": "#000000", + "bold": true, + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "03 HUMAN REVIEW", + "fontSize": 17, + "color": "#000000", + "bold": true, + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 11, + "kind": "shape", + "scope": "slide", + "aid": "sh/xs7qp0nq", + "id": "11", + "bbox": [ + 832, + 558, + 350, + 70 + ], + "geometry": "rect", + "text": "No mutation before explicit Accept", + "textPreview": "No mutation before explicit Accept", + "resolvedFontSize": 18, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 18, + "typeface": "+mn-lt", + "color": "#41464E", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "No mutation before explicit Accept" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "No mutation before explicit Accept", + "resolvedTextStyle": { + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "No mutation before explicit Accept", + "fontSize": 18, + "color": "#41464E", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 12, + "kind": "shape", + "scope": "slide", + "aid": "sh/apg7elof", + "id": "12", + "name": "footer-label-5", + "bbox": [ + 42, + 670, + 520, + 22 + ], + "geometry": "rect", + "text": "NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI", + "textPreview": "NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#6E747C", + "alignment": "left", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + }, + "runs": [ + { + "index": 1, + "text": "NEXT · SOURCE BINDING + SERVER-GENERATED OPENUI", + "fontSize": 13, + "color": "#6E747C", + "alignment": "left" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + }, + { + "order": 13, + "kind": "shape", + "scope": "slide", + "aid": "sh/vqp8nq50", + "id": "13", + "name": "footer-page-5", + "bbox": [ + 1184, + 670, + 54, + 22 + ], + "geometry": "rect", + "text": "05", + "textPreview": "05", + "resolvedFontSize": 13, + "resolvedTextStyle": { + "anchor": 1, + "fontSize": 13, + "typeface": "+mn-lt", + "color": "#000000", + "alignment": "right", + "verticalAlignment": "top", + "insets": { + "top": 4.8, + "right": 9.6, + "bottom": 4.8, + "left": 9.6 + } + }, + "textLayout": { + "lineCount": 1, + "lines": [ + { + "index": 1, + "text": "05" + } + ] + }, + "paragraphs": [ + { + "index": 1, + "text": "05", + "resolvedTextStyle": { + "fontSize": 13, + "color": "#000000", + "alignment": "right" + }, + "runs": [ + { + "index": 1, + "text": "05", + "fontSize": 13, + "color": "#000000", + "alignment": "right" + } + ], + "marginLeft": 0 + } + ], + "lineWidth": 0 + } + ], + "composeRuns": [] +} \ No newline at end of file diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-05.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-05.png new file mode 100644 index 0000000..4d7305f Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/deck-render/slide-05.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/findings.jsonl b/docs/dogfood/nodeslide-openui-live-2026-07-16/findings.jsonl new file mode 100644 index 0000000..5ca1c28 --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/findings.jsonl @@ -0,0 +1,4 @@ +{"ts":"2026-07-16T01:16:39.0983474-07:00","id":"openui-program-root-2026-07-16","sev":"P1","area":"nodeslide-openui-rendering","symptom":"The initial OpenUI visual-material program used markup form and produced an empty renderer root with the installed language runtime.","rootCause":"The installed lang-core runtime accepts the v0.5 statement form expected by its prompt, while the initial fixture mirrored an incompatible README markup example.","evidence":"Live empty render followed by a valid expanded ladder; compiler test now asserts a non-null root and exactly one statement.","fix":"Changed the fixture to root = TransformationLadder(...) statement syntax and added parser-root regression coverage.","status":"fixed"} +{"ts":"2026-07-16T01:16:39.0983474-07:00","id":"add-slide-candidate-selection-2026-07-16","sev":"P1","area":"nodeslide-proposal-compare","symptom":"Compare initially reused the current baseline slide for an add_slide proposal instead of showing the newly added candidate slide.","rootCause":"Candidate selection defaulted to the current slide id even when the patch operation created a new slide.","evidence":"Disposable live v1 proposal/compare journey; corrected comparison then showed all four scenario values and guardrails before Accept.","fix":"Added candidateSlideIdForPatch and an A11 regression test so add_slide previews the operation slide id while the baseline remains unchanged.","status":"fixed"} +{"ts":"2026-07-16T02:56:50-07:00","id":"openui-workbench-viewport-remount-2026-07-16","sev":"P1","area":"nodeslide-openui-interaction","symptom":"The live proposal button was repeatedly replaced before its handler could complete, even though isolated DOM interaction tests passed.","rootCause":"The workbench was mounted inside the assistant-ui ThreadPrimitive.Viewport, whose live runtime owns and remounts message content.","evidence":"Stable locator counts followed by stale DOM nodes and no proposal; moving the workbench above the viewport produced a durable receipt and Compare state immediately.","fix":"Mounted the visual-material tool as a stable sibling of ThreadPrimitive.Viewport inside the thread root.","status":"fixed"} +{"ts":"2026-07-16T02:56:50-07:00","id":"human-preview-ai-review-visibility-2026-07-16","sev":"P1","area":"nodeslide-proposal-review","symptom":"The OpenUI proposal entered Compare with a durable receipt but did not expose Accept or Reject in the AI review stream.","rootCause":"The shared JSON proposal mutation correctly records client-authored patches as human, while AiInspector previously displayed only agent-source patches.","evidence":"Compare became active and the candidate receipt existed, but the review card was absent; matching the active preview id surfaced exactly one proposal with enabled Accept and Reject.","fix":"AiInspector now includes the active previewed patch id in its deduplicated review list regardless of source while retaining the agent-source filter for all other patches.","status":"fixed"} diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-ladder-expanded.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-ladder-expanded.png new file mode 100644 index 0000000..aefb67f Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-ladder-expanded.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-material-preview.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-material-preview.png new file mode 100644 index 0000000..d24b4a5 Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-material-preview.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-proposal-compare.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-proposal-compare.png new file mode 100644 index 0000000..df86e95 Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-proposal-compare.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-slide-accepted.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-slide-accepted.png new file mode 100644 index 0000000..62a85e9 Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-slide-accepted.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-slide-dark.png b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-slide-dark.png new file mode 100644 index 0000000..01573fc Binary files /dev/null and b/docs/dogfood/nodeslide-openui-live-2026-07-16/openui-slide-dark.png differ diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/run.json b/docs/dogfood/nodeslide-openui-live-2026-07-16/run.json new file mode 100644 index 0000000..220b5db --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/run.json @@ -0,0 +1,49 @@ +{ + "ts": "2026-07-16T02:56:50-07:00", + "executor": "Codex", + "app": "nodeslide", + "scope": "OpenUI Phase 0 visual-material proposal and live agent interaction proof", + "environment": { + "target": "http://127.0.0.1:5192/", + "workspace": "disposable local sample", + "productionMutation": false, + "deployment": false + }, + "journeys": { + "openui-render": "PASS", + "allowlisted-proposal": "PASS", + "pending-deck-unchanged": "PASS-v1", + "candidate-receipt": "PASS-durable-and-accept-enabled", + "candidate-compare": "PASS-new-add-slide-previewed", + "explicit-accept": "PASS-v1-to-v2-exactly-once", + "review-stream-close": "PASS-after-accept", + "accepted-slide-visible": "PASS", + "browser-console": "PASS-0-errors-0-warnings", + "light-theme": "PASS", + "dark-theme": "PASS", + "mobile-viewport": "NOT-CLAIMED-in-app-browser-override-remained-1280x720" + }, + "agentInteractionContract": { + "openuiAction": "nodeslide.visual-material.propose", + "operations": 1, + "operationKind": "add_slide", + "silentMutation": false, + "humanAcceptRequired": true, + "acceptRequiresDurableReceipt": true, + "mixedUnitSharedAxis": false, + "sourceLabel": "User-provided scenario brief · unverified" + }, + "gates": { + "focusedOpenuiAgentTests": "17/17 passed", + "fullRepositoryTests": "1113/1113 passed", + "typecheck": "passed", + "biomeFocused": "passed", + "viteProductionBundle": "passed", + "pptxOverflow": "passed", + "slideElementBounds": "5 slides, 0 out-of-bounds elements", + "visualMontageReview": "passed" + }, + "evidenceDir": "docs/dogfood/nodeslide-openui-live-2026-07-16", + "deck": "artifacts/nodeslide-openui-live-agent-proof-2026-07-16.pptx", + "notes": "The final live run also fixed two integration defects: the visual workbench was moved outside the assistant-ui viewport so its controls are stable, and the active human-origin JSON proposal is now surfaced in the AI review stream when its preview id matches. The disposable deck advanced exactly once from v1 to v2." +} diff --git a/docs/dogfood/nodeslide-openui-live-2026-07-16/summary.md b/docs/dogfood/nodeslide-openui-live-2026-07-16/summary.md new file mode 100644 index 0000000..a576366 --- /dev/null +++ b/docs/dogfood/nodeslide-openui-live-2026-07-16/summary.md @@ -0,0 +1,9 @@ +# NodeSlide OpenUI Phase 0 — live QA summary + +- OpenUI is the bounded visual-decision surface; SlideLang remains the editable document layer and Convex remains the proposal/authority layer. +- The AI 2027 fixture renders four incompatible units as a transformation ladder, never a shared quantitative axis. +- The only allowlisted OpenUI action compiles one editable `add_slide` proposal. The deck stays at v1 until a durable candidate receipt unlocks explicit Accept, then advances exactly once to v2. +- Live proof covered render, propose, receipt, compare, accept, review-stream closure, accepted-slide visibility, and zero browser warnings/errors in a disposable local workspace. +- Four defects discovered across the combined runs were fixed and protected by tests: OpenUI root parsing, `add_slide` candidate selection, unstable controls inside the assistant viewport, and human-origin preview proposals not appearing in the AI review stream. +- Gates: 17/17 focused OpenUI interaction tests, 1,113/1,113 repository tests, typecheck, focused Biome, Vite production bundling, PPTX overflow, layout bounds, and montage review passed. +- Mobile responsive pixels are not claimed: the in-app browser retained a 1280×720 viewport after the override request. diff --git a/docs/nodeslide-mcp.md b/docs/nodeslide-mcp.md index 33bc1f9..a467297 100644 --- a/docs/nodeslide-mcp.md +++ b/docs/nodeslide-mcp.md @@ -98,6 +98,7 @@ starts unconsented. Deterministic calls do not need or carry consent. | `nodeslide.get_deck` | Read-only structured deck summary + receipt | | `nodeslide.get_snapshot` | Read-only canonical snapshot with version clocks | | `nodeslide.list_elements` | Bounded, paginated structured element listing | +| `nodeslide.evaluate_quality` | Read-only release preflight for story, evidence, visual craft, editability, reference quality, and recorded journey proof | | `nodeslide.export_spec` | Versioned `nodeslide.deck-snapshot` JSON envelope | | `nodeslide.list_slides` | Read-only slides and version clocks | | `nodeslide.get_trace` | Read-only model/cost/token/digest/validation trace | @@ -120,6 +121,7 @@ starts unconsented. Deterministic calls do not need or carry consent. slideId="slide_1", consent=true, and an explicit instruction. 5. Inspect candidateReceipt and nodeslide.get_trace. 6. Stop for human review. Do not call accept_proposal unless the user explicitly approves. +7. After acceptance and export, call nodeslide.evaluate_quality with the verified journey-proof JSON. Missing browser video, GIF, screenshot, editable PPTX, manifest, reference receipt, or an exact +1 version transition remains a release blocker. ``` The proposal response includes `applied: false` and identical before/after deck versions. A mismatch fails closed as a governance violation. diff --git a/mcp/src/lib/nodeslideTools.test.ts b/mcp/src/lib/nodeslideTools.test.ts index 7f3b9ee..f46a65e 100644 --- a/mcp/src/lib/nodeslideTools.test.ts +++ b/mcp/src/lib/nodeslideTools.test.ts @@ -440,6 +440,7 @@ describe('NodeSlide MCP governance', () => { expect.arrayContaining([ 'nodeslide.get_snapshot', 'nodeslide.list_elements', + 'nodeslide.evaluate_quality', 'nodeslide.export_spec', 'nodeslide.propose_patch', ]), @@ -573,4 +574,34 @@ describe('NodeSlide MCP governance', () => { expect(calls.some((call) => call.kind === 'mutation')).toBe(false); expect(JSON.stringify(response)).not.toContain('owner-key'); }); + + it('exposes a read-only fail-closed presentation-quality preflight', async () => { + const calls: Array<{ kind: string; path: string; args: Record }> = []; + const { handlers } = registerToolHarness(async (kind, path, args) => { + calls.push({ kind, path, args }); + if (path === 'nodeslideAuthoringQuality:evaluateLatest') { + return { + releaseReady: false, + receipt: { status: 'fail', blockerCount: 1, issues: [{ code: 'journey_proof_missing' }] }, + }; + } + throw new Error(`Unexpected path ${path}`); + }); + + const result = await handlers.get('nodeslide.evaluate_quality')?.({ + deckId: 'deck_1', + ownerAccessKey: 'owner-key', + }); + expect(JSON.parse(result?.content[0]?.text ?? '{}')).toMatchObject({ + releaseReady: false, + receipt: { status: 'fail', blockerCount: 1 }, + }); + expect(calls).toEqual([ + { + kind: 'query', + path: 'nodeslideAuthoringQuality:evaluateLatest', + args: { deckId: 'deck_1', ownerAccessKey: 'owner-key' }, + }, + ]); + }); }); diff --git a/mcp/src/lib/nodeslideTools.ts b/mcp/src/lib/nodeslideTools.ts index 94a6ec0..baa2b17 100644 --- a/mcp/src/lib/nodeslideTools.ts +++ b/mcp/src/lib/nodeslideTools.ts @@ -596,6 +596,32 @@ export function registerNodeSlideTools(server: McpServer, convexCall: ConvexCall }, ); + server.registerTool( + 'nodeslide.evaluate_quality', + { + title: 'Evaluate NodeSlide presentation quality', + description: + 'Owner-gated, read-only release preflight across communication job, narrative, evidence, visual craft, editability, reference comparison, and recorded journey proof. It fails closed when mandatory proof is missing and never accepts a proposal.', + inputSchema: { + ...ownerArgs, + journeyProofJson: z.string().max(200_000).optional(), + referenceReceiptJson: z.string().max(200_000).optional(), + }, + annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, + }, + async (args) => { + const key = resolveOwnerKey(args.deckId, args.ownerAccessKey); + return textResult( + await convexCall('query', 'nodeslideAuthoringQuality:evaluateLatest', { + deckId: args.deckId, + ownerAccessKey: key, + ...(args.journeyProofJson ? { journeyProofJson: args.journeyProofJson } : {}), + ...(args.referenceReceiptJson ? { referenceReceiptJson: args.referenceReceiptJson } : {}), + }), + ); + }, + ); + server.registerTool( 'nodeslide.export_spec', { diff --git a/package.json b/package.json index 2fc7756..a601814 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,17 @@ "proof:nodeslide:agentic:local": "node scripts/nodeslide-agentic-local-switch-proof.mjs", "proof:nodeslide:ui": "node scripts/nodeslide-agent-operability-proof.mjs", "proof:nodeslide:hero": "node scripts/nodeslide-production-hero-proof.mjs", + "proof:nodeslide:journey": "node scripts/nodeslide-journey-proof.mjs", + "proof:nodeslide:journey:gif": "node scripts/nodeslide-journey-gif.mjs", + "nodeslide:authoring:meta": "node scripts/nodeslide-authoring-meta.mjs", + "test:e2e:nodeslide:journey": "node scripts/run-nodeslide-journey-e2e.mjs", "nodeslide:bench": "node scripts/nodeslide-benchmark-gate.mjs --mode evidence", "nodeslide:bench:pr": "node scripts/nodeslide-benchmark-gate.mjs --mode pr", "nodeslide:bench:evidence": "node scripts/nodeslide-benchmark-gate.mjs --mode evidence", "nodeslide:bench:produce-live": "playwright test tests/e2e/nodeslide-benchmark-producer.live.spec.ts --retries=0 --workers=1", "nodeslide:bench:taste-judge": "node scripts/nodeslide-taste-judge.mjs", - "test:nodeslide-bench": "vitest run scripts/tests/nodeslide-benchmark-gate.test.mjs scripts/tests/nodeslide-uxbench.test.mjs scripts/tests/nodeslide-tastebench.test.mjs scripts/tests/nodeslide-benchmark-producer.test.mjs scripts/tests/nodeslide-taste-judge.test.mjs", + "nodeslide:bench:competitive": "node scripts/nodeslide-competitive-benchmark.mjs", + "test:nodeslide-bench": "vitest run scripts/tests/nodeslide-benchmark-gate.test.mjs scripts/tests/nodeslide-uxbench.test.mjs scripts/tests/nodeslide-tastebench.test.mjs scripts/tests/nodeslide-benchmark-producer.test.mjs scripts/tests/nodeslide-taste-judge.test.mjs scripts/tests/nodeslide-competitive-benchmark.test.mjs", "qa:manifest": "node scripts/validate-qa-manifest.mjs", "check:built-runtime-source": "node scripts/check-built-runtime-source.mjs", "check:runtime-source": "node scripts/check-runtime-source.mjs", @@ -49,6 +54,7 @@ "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/jetbrains-mono": "^5.2.8", "@monaco-editor/react": "^4.7.0", + "@openuidev/react-lang": "0.2.8", "@sinclair/typebox": "^0.34.0", "ai": "^7.0.26", "class-variance-authority": "^0.7.1", @@ -58,6 +64,7 @@ "jszip": "^3.10.1", "lucide-react": "^1.12.0", "nanoid": "^6.0.0", + "pdfjs-dist": "^6.1.200", "pptxgenjs": "^4.0.1", "radix-ui": "^1.6.2", "react": "^19.0.0", diff --git a/playwright.config.ts b/playwright.config.ts index 3b0b111..7c83dfd 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import { defineConfig } from 'playwright/test'; const remoteBaseUrl = process.env['PLAYWRIGHT_BASE_URL']; +const journeyProofEnabled = process.env['NODESLIDE_JOURNEY_PROOF'] === '1'; const baseURL = remoteBaseUrl ?? 'http://127.0.0.1:4173'; const vercelBypassSecret = process.env['VERCEL_AUTOMATION_BYPASS_SECRET']?.trim(); const bypassStorageState = join( @@ -36,8 +37,8 @@ export default defineConfig({ // Protected-preview traces can serialize the bypass cookie. Keep that // credential out of uploaded artifacts while retaining screenshots/video. trace: remoteBaseUrl && vercelBypassSecret ? 'off' : 'retain-on-failure', - screenshot: 'only-on-failure', - video: 'retain-on-failure', + screenshot: journeyProofEnabled ? 'on' : 'only-on-failure', + video: journeyProofEnabled ? 'on' : 'retain-on-failure', }, webServer: remoteBaseUrl ? undefined diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7237c3..abb274f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: '@monaco-editor/react': specifier: ^4.7.0 version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@openuidev/react-lang': + specifier: 0.2.8 + version: 0.2.8(react@19.2.5)(zod@4.3.6) '@sinclair/typebox': specifier: ^0.34.0 version: 0.34.49 @@ -71,6 +74,9 @@ importers: nanoid: specifier: ^6.0.0 version: 6.0.0 + pdfjs-dist: + specifier: ^6.1.200 + version: 6.1.200 pptxgenjs: specifier: ^4.0.1 version: 4.0.1 @@ -1089,6 +1095,81 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@napi-rs/canvas-android-arm64@1.0.2': + resolution: {integrity: sha512-IMXKVQod0ol4vt3gmClUfXz4JAgHYESGPCUqmH3lQxBoL0K/2greJaQE1HVBVxWWFKfLc4OLZVdxg7kXVyXv+g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@1.0.2': + resolution: {integrity: sha512-Sc8tPi6cF+5lqOzCCKFALJHhDiRwyMzTPYm3bbhdXsOunU0lQO5f05ucyOzN2r55I23Hg5bsjH63uSCvWp3EgQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@1.0.2': + resolution: {integrity: sha512-niDXZ9LhKB1zLrUdYB64RHQFDGz9rr0eGx061qtJJU3U20EMMIx28ADF5fVYbhtOgkWQrBjFicfaye1yM0U62A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-sgatQL9JxGRH/Amzcvu0P3t8Am3duou74CisfuJ41Dwt8cWy723z/9KZ8LlgmxfypEwEZxSTNFJtU8d281lmhQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-dgKuX0peF3xwY6ZF5QxGS4wbfDqpoFAJYXiLSp+guZKARQUKMkRqZSDrXKj7nfrec3UCMzC0PFCPte0ES98AiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-qwROoDIC9upfvDoRLuPn2aNg9CGW1x0Ygr4k2Or+8paA9d0qBLwk87U+g8KQpoOviKoPoiwl97kvBYuYD7qZoA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + resolution: {integrity: sha512-fXRjnPihdnbO6qy1QQOgxAonb68A0TCEG7rj1x7v7rxNElsE8EVIKIEUTvyDtU+sthYSbX+8e7g3oZiLGnOmxw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-nPR97DXhbWIAy7yazF3jc06kEPMqYMLmPzFOVNlwKPfIoSChnI+x7dc0hTLaihz3jxrjL6j4BbA7earxfx4X3g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-l7zZY5+jL5qnBZtDz7CoBtY6p7EkHu422g/0zWwrOrzIwWyWxZFRfZZORY1UG7YApymPLx+UbOkN206xXn/c1Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-yE0koHCFF4PIbMc2o2SEALhnipz7WBISh5glLvQiomtIoCcW0np3H4Lw93ceJAfJttTTeIIWFbwH84F7EVzjMQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-okU8/t2foV6C31n0GtvEMbfD5rOFc70+/6xUNME9Guld29sgSOIGUEDScAWFlcP3k5TYQRl9TNkwJEEjh15w8A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@1.0.2': + resolution: {integrity: sha512-EYEqlMYaCbpZDz+IgDH5xp9MTd3ui4dmGqbQYryhMLnSRxrhHKq5KQWHHKxFUcEP4Hp8/BWgvqXocX4j7iSbOQ==} + engines: {node: '>= 10'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -1097,6 +1178,25 @@ packages: resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} engines: {node: '>=14'} + '@openuidev/lang-core@0.2.9': + resolution: {integrity: sha512-LRSzjCUTuNAAUNlO5xbKDJYoe1PGoPXVosK1QTexBOD9mzOHk2Da5/BuXEqHBVdRoH+y4upfm3y9CTb2Z/Q4fg==} + peerDependencies: + '@modelcontextprotocol/sdk': '>=1.0.0' + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + + '@openuidev/react-lang@0.2.8': + resolution: {integrity: sha512-SsbZes76oIviuZH3RW/VL+pZZjx6cOGjhkaeiSwuEnV24jWBgT9qswL4bDaQXpFG2e6sZKWjH/f0fRyqWQzhDA==} + peerDependencies: + '@modelcontextprotocol/sdk': '>=1.0.0' + react: ^18.3.1 || ^19.0.0 + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + '@modelcontextprotocol/sdk': + optional: true + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2817,6 +2917,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdfjs-dist@6.1.200: + resolution: {integrity: sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==} + engines: {node: '>=22.13.0 || >=24'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4228,10 +4332,68 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@napi-rs/canvas-android-arm64@1.0.2': + optional: true + + '@napi-rs/canvas-darwin-arm64@1.0.2': + optional: true + + '@napi-rs/canvas-darwin-x64@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@1.0.2': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@1.0.2': + optional: true + + '@napi-rs/canvas-linux-x64-musl@1.0.2': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@1.0.2': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@1.0.2': + optional: true + + '@napi-rs/canvas@1.0.2': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 1.0.2 + '@napi-rs/canvas-darwin-arm64': 1.0.2 + '@napi-rs/canvas-darwin-x64': 1.0.2 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.2 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.2 + '@napi-rs/canvas-linux-arm64-musl': 1.0.2 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-gnu': 1.0.2 + '@napi-rs/canvas-linux-x64-musl': 1.0.2 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.2 + '@napi-rs/canvas-win32-x64-msvc': 1.0.2 + optional: true + '@opentelemetry/api@1.9.0': {} '@opentelemetry/semantic-conventions@1.43.0': {} + '@openuidev/lang-core@0.2.9(zod@4.3.6)': + dependencies: + zod: 4.3.6 + + '@openuidev/react-lang@0.2.8(react@19.2.5)(zod@4.3.6)': + dependencies: + '@openuidev/lang-core': 0.2.9(zod@4.3.6) + react: 19.2.5 + zod: 4.3.6 + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -5932,6 +6094,10 @@ snapshots: pathval@2.0.1: {} + pdfjs-dist@6.1.200: + optionalDependencies: + '@napi-rs/canvas': 1.0.2 + picocolors@1.1.1: {} picomatch@4.0.4: {} diff --git a/scripts/nodeslide-authoring-meta.mjs b/scripts/nodeslide-authoring-meta.mjs new file mode 100644 index 0000000..4d5c5c0 --- /dev/null +++ b/scripts/nodeslide-authoring-meta.mjs @@ -0,0 +1,175 @@ +import { createHash } from 'node:crypto'; +import { appendFile, mkdir, readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const AUTHORING_ARCHIVE_VERSION = 'nodeslide.authoring-archive/v1'; + +export async function appendAuthoringArchiveRecord(filePath, entry, now = Date.now()) { + validateEntry(entry); + const records = await loadAuthoringArchive(filePath); + if (records.some((record) => record.entry.policy.id === entry.policy.id)) { + throw new Error(`Policy ${entry.policy.id} is already archived.`); + } + const previousDigest = records.at(-1)?.recordDigest ?? null; + const partial = { + schemaVersion: AUTHORING_ARCHIVE_VERSION, + sequence: records.length + 1, + previousDigest, + recordedAt: now, + entry, + }; + const record = { ...partial, recordDigest: durableDigest(partial) }; + await mkdir(path.dirname(path.resolve(filePath)), { recursive: true }); + await appendFile(path.resolve(filePath), `${JSON.stringify(record)}\n`, 'utf8'); + return record; +} + +export async function loadAuthoringArchive(filePath) { + const source = await readFile(path.resolve(filePath), 'utf8').catch((error) => { + if (error?.code === 'ENOENT') return ''; + throw error; + }); + const records = source + .split(/\r?\n/u) + .filter(Boolean) + .map((line) => JSON.parse(line)); + let previousDigest = null; + for (const [index, record] of records.entries()) { + const { recordDigest, ...partial } = record; + if ( + record.schemaVersion !== AUTHORING_ARCHIVE_VERSION || + record.sequence !== index + 1 || + record.previousDigest !== previousDigest || + recordDigest !== durableDigest(partial) + ) { + throw new Error(`Authoring archive hash chain is invalid at sequence ${index + 1}.`); + } + validateEntry(record.entry); + previousDigest = recordDigest; + } + return records; +} + +export function authoringParetoFront(entries) { + const eligible = entries.filter( + ({ policy, evaluation }) => + policy.id === evaluation.policyId && + evaluation.heldOut === true && + evaluation.journeyProofPassed === true && + evaluation.safety >= 0 && + evaluation.exportFidelity >= 0 && + mandatoryRolesEnabled(policy), + ); + return eligible + .filter( + (candidate) => + !eligible.some( + (other) => other !== candidate && dominates(other.evaluation, candidate.evaluation), + ), + ) + .sort( + (left, right) => + right.evaluation.quality - left.evaluation.quality || + right.evaluation.safety - left.evaluation.safety || + right.evaluation.exportFidelity - left.evaluation.exportFidelity || + left.evaluation.costMicroUsd - right.evaluation.costMicroUsd || + left.evaluation.latencyMs - right.evaluation.latencyMs || + left.policy.id.localeCompare(right.policy.id), + ); +} + +export function authoringCandidatePromotable(baseline, candidate) { + return ( + candidate.policy.parentId === baseline.policy.id && + candidate.evaluation.heldOut === true && + candidate.evaluation.journeyProofPassed === true && + candidate.evaluation.quality > baseline.evaluation.quality && + candidate.evaluation.safety >= baseline.evaluation.safety && + candidate.evaluation.exportFidelity >= baseline.evaluation.exportFidelity && + mandatoryRolesEnabled(candidate.policy) + ); +} + +function validateEntry(entry) { + if (!entry?.policy?.id || entry?.evaluation?.policyId !== entry.policy.id) + throw new Error('Policy and evaluation bindings are invalid.'); + if (!mandatoryRolesEnabled(entry.policy)) + throw new Error('Mandatory safety and proof roles must remain enabled.'); + for (const field of ['quality', 'safety', 'exportFidelity']) { + const value = entry.evaluation[field]; + if (!Number.isFinite(value) || value < 0 || value > 100) + throw new Error(`${field} must be between 0 and 100.`); + } + for (const field of ['costMicroUsd', 'latencyMs']) { + if (!Number.isSafeInteger(entry.evaluation[field]) || entry.evaluation[field] < 0) + throw new Error(`${field} is invalid.`); + } +} + +function mandatoryRolesEnabled(policy) { + return ( + policy?.roles?.claim_verifier?.enabled === true && + policy?.roles?.export_parity_critic?.enabled === true && + policy?.roles?.journey_capture_agent?.enabled === true + ); +} + +function dominates(left, right) { + const noWorse = + left.quality >= right.quality && + left.safety >= right.safety && + left.exportFidelity >= right.exportFidelity && + left.costMicroUsd <= right.costMicroUsd && + left.latencyMs <= right.latencyMs; + return ( + noWorse && + (left.quality > right.quality || + left.safety > right.safety || + left.exportFidelity > right.exportFidelity || + left.costMicroUsd < right.costMicroUsd || + left.latencyMs < right.latencyMs) + ); +} + +function durableDigest(value) { + return `sha256:${createHash('sha256').update(stableSerialize(value)).digest('hex')}`; +} +function stableSerialize(value) { + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number') return JSON.stringify(value); + if (typeof value === 'undefined') return 'undefined'; + if (Array.isArray(value)) return `[${value.map(stableSerialize).join(',')}]`; + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`) + .join(',')}}`; +} + +async function main() { + const [command, archive = 'artifacts/nodeslide-authoring-meta/archive.jsonl', recordPath] = + process.argv.slice(2); + if (command === 'append') { + if (!recordPath) + throw new Error('Usage: nodeslide:authoring:meta append '); + const entry = JSON.parse(await readFile(path.resolve(recordPath), 'utf8')); + process.stdout.write( + `${JSON.stringify(await appendAuthoringArchiveRecord(archive, entry), null, 2)}\n`, + ); + return; + } + const records = await loadAuthoringArchive(archive); + const front = authoringParetoFront(records.map((record) => record.entry)); + process.stdout.write( + `${JSON.stringify({ records: records.length, paretoFront: front, selectedParent: front[0] ?? null }, null, 2)}\n`, + ); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/nodeslide-competitive-benchmark.mjs b/scripts/nodeslide-competitive-benchmark.mjs new file mode 100644 index 0000000..d5e84d2 --- /dev/null +++ b/scripts/nodeslide-competitive-benchmark.mjs @@ -0,0 +1,172 @@ +import { createHash } from 'node:crypto'; +import { readFile, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; + +export const COMPETITIVE_BENCHMARK_SCHEMA = 'nodeslide-competitive-benchmark/v1'; +export const COMPETITIVE_SYSTEMS = Object.freeze(['gamma', 'canva', 'nodeslide']); + +const REQUIRED_METRICS = Object.freeze([ + 'timeToFirstUsefulDeckSeconds', + 'supportedClaims', + 'totalClaims', + 'exactChartValues', + 'totalChartValues', + 'unsupportedClaims', + 'manualCorrections', + 'unrelatedChangesFromScopedEdit', + 'explainsChanges', + 'rejectPreservesDeck', + 'correctlyAffectedSlides', + 'totalAffectedSlides', + 'editablePptxObjects', + 'totalPptxObjects', + 'pptxFidelityPercent', + 'timeToUpdatedVersionSeconds', + 'audiencePreferenceWins', + 'audiencePreferencePairs', +]); + +export function evaluateCompetitiveBenchmark(input) { + const issues = validateInput(input); + if (issues.length > 0) { + return Object.freeze({ + schemaVersion: COMPETITIVE_BENCHMARK_SCHEMA, + status: 'unscored', + issues: Object.freeze(issues), + systems: Object.freeze([]), + positioning: null, + digest: benchmarkDigest({ issues }), + }); + } + + const systems = COMPETITIVE_SYSTEMS.map((system) => { + const run = input.runs.find((candidate) => candidate.system === system); + return Object.freeze({ + system, + ...derivedMetrics(run.metrics), + evidenceRefs: run.evidenceRefs, + }); + }); + const bySystem = Object.fromEntries(systems.map((row) => [row.system, row])); + const nodeslide = bySystem.nodeslide; + const positioning = Object.freeze({ + firstDraftSpeedLeader: minimumLeader(systems, 'timeToFirstUsefulDeckSeconds'), + designFlexibilityLeader: maximumLeader(systems, 'audiencePreferenceRate'), + traceabilityLeader: maximumLeader(systems, 'claimSupportRate'), + controlledRevisionLeader: minimumLeader(systems, 'unrelatedChangesFromScopedEdit'), + dataFidelityLeader: maximumLeader(systems, 'chartFidelityRate'), + refreshLeader: minimumLeader(systems, 'timeToUpdatedVersionSeconds'), + nodeSlideHypothesisEarned: + nodeslide.claimSupportRate >= + Math.max(bySystem.gamma.claimSupportRate, bySystem.canva.claimSupportRate) && + nodeslide.chartFidelityRate >= + Math.max(bySystem.gamma.chartFidelityRate, bySystem.canva.chartFidelityRate) && + nodeslide.unrelatedChangesFromScopedEdit <= + Math.min( + bySystem.gamma.unrelatedChangesFromScopedEdit, + bySystem.canva.unrelatedChangesFromScopedEdit, + ) && + nodeslide.timeToUpdatedVersionSeconds <= + Math.min( + bySystem.gamma.timeToUpdatedVersionSeconds, + bySystem.canva.timeToUpdatedVersionSeconds, + ) && + nodeslide.explainsChanges && + nodeslide.rejectPreservesDeck, + }); + const report = { + schemaVersion: COMPETITIVE_BENCHMARK_SCHEMA, + status: 'scored', + evidencePackDigest: input.evidencePackDigest, + systems, + positioning, + }; + return Object.freeze({ ...report, digest: benchmarkDigest(report) }); +} + +function validateInput(input) { + const issues = []; + if (!input || typeof input !== 'object' || Array.isArray(input)) + return ['input must be an object']; + if (input.schemaVersion !== COMPETITIVE_BENCHMARK_SCHEMA) issues.push('schemaVersion is invalid'); + if (!/^sha256:[0-9a-f]{64}$/.test(input.evidencePackDigest ?? '')) { + issues.push('evidencePackDigest must bind every run to one immutable evidence pack'); + } + if (!Array.isArray(input.runs)) return [...issues, 'runs must be an array']; + for (const system of COMPETITIVE_SYSTEMS) { + const candidates = input.runs.filter((run) => run?.system === system); + if (candidates.length !== 1) { + issues.push(`${system} must have exactly one run`); + continue; + } + const run = candidates[0]; + if (!Array.isArray(run.evidenceRefs) || run.evidenceRefs.length === 0) { + issues.push(`${system} must include artifact evidence`); + } + for (const metric of REQUIRED_METRICS) { + if (!(metric in (run.metrics ?? {}))) issues.push(`${system}.${metric} is missing`); + } + for (const [key, value] of Object.entries(run.metrics ?? {})) { + if (typeof value !== 'number' && typeof value !== 'boolean') { + issues.push(`${system}.${key} must be numeric or boolean`); + } + if (typeof value === 'number' && (!Number.isFinite(value) || value < 0)) { + issues.push(`${system}.${key} must be a finite non-negative number`); + } + } + } + return [...new Set(issues)].sort(); +} + +function derivedMetrics(metrics) { + return Object.freeze({ + ...metrics, + claimSupportRate: ratio(metrics.supportedClaims, metrics.totalClaims), + chartFidelityRate: ratio(metrics.exactChartValues, metrics.totalChartValues), + affectedSlidePrecision: ratio(metrics.correctlyAffectedSlides, metrics.totalAffectedSlides), + pptxEditabilityRate: ratio(metrics.editablePptxObjects, metrics.totalPptxObjects), + audiencePreferenceRate: ratio(metrics.audiencePreferenceWins, metrics.audiencePreferencePairs), + }); +} + +function ratio(numerator, denominator) { + return denominator > 0 ? Number((numerator / denominator).toFixed(6)) : 0; +} + +function minimumLeader(rows, key) { + return [...rows].sort((a, b) => a[key] - b[key] || a.system.localeCompare(b.system))[0].system; +} + +function maximumLeader(rows, key) { + return [...rows].sort((a, b) => b[key] - a[key] || a.system.localeCompare(b.system))[0].system; +} + +function benchmarkDigest(value) { + return `sha256:${createHash('sha256').update(stableStringify(value)).digest('hex')}`; +} + +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +async function main() { + const inputPath = process.argv[2]; + if (!inputPath) + throw new Error( + 'Usage: node scripts/nodeslide-competitive-benchmark.mjs [output.json]', + ); + const report = evaluateCompetitiveBenchmark(JSON.parse(await readFile(inputPath, 'utf8'))); + const serialized = `${JSON.stringify(report, null, 2)}\n`; + if (process.argv[3]) await writeFile(process.argv[3], serialized, 'utf8'); + else process.stdout.write(serialized); + if (report.status !== 'scored') process.exitCode = 2; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) await main(); diff --git a/scripts/nodeslide-journey-gif.mjs b/scripts/nodeslide-journey-gif.mjs new file mode 100644 index 0000000..f0f5afd --- /dev/null +++ b/scripts/nodeslide-journey-gif.mjs @@ -0,0 +1,54 @@ +import { spawnSync } from 'node:child_process'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +export async function createNodeSlideJourneyGif({ input, output, width = 960, fps = 12 }) { + const source = path.resolve(input); + const target = path.resolve(output); + const temporary = await mkdtemp(path.join(tmpdir(), 'nodeslide-journey-gif-')); + const palette = path.join(temporary, 'palette.png'); + try { + run([ + '-y', + '-i', + source, + '-vf', + `fps=${fps},scale=${width}:-1:flags=lanczos,palettegen=stats_mode=diff`, + palette, + ]); + run([ + '-y', + '-i', + source, + '-i', + palette, + '-filter_complex', + `fps=${fps},scale=${width}:-1:flags=lanczos[x];[x][1:v]paletteuse=dither=bayer:bayer_scale=4:diff_mode=rectangle`, + '-loop', + '0', + target, + ]); + return target; + } finally { + await rm(temporary, { recursive: true, force: true }); + } +} + +function run(args) { + const result = spawnSync('ffmpeg', args, { stdio: 'inherit' }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`ffmpeg exited with status ${result.status}.`); +} + +const args = process.argv.slice(2); +const inputIndex = args.indexOf('--input'); +const outputIndex = args.indexOf('--output'); +if (inputIndex >= 0 || outputIndex >= 0) { + const input = args[inputIndex + 1]; + const output = args[outputIndex + 1]; + if (!input || !output) + throw new Error('Usage: node scripts/nodeslide-journey-gif.mjs --input