TODOs
Decision log, open spikes, debt registry (dogfood copy)
Maintained by plan reviews. Effort: S/M/L/XL · Priority: P1/P2/P3.
Decision log (CEO plan review, 2026-07-01 — all resolved)
- ✅ Approach B′ (moat-first milestone order) — approved; applied to SPEC.md §21.
- ✅ Expansions accepted into v1: MCP server · approvals lite · suggested edits · digest post-back · instant demo mode · version diff + comment overlay · review queue. All folded into SPEC.md Rev 2.
- ✅ Plain-text projection owner: web layer (SPEC §5.4),
projection_versionon anchors, conformance fixtures in CI. - ✅ All review-mandated amendments (idempotency, sync-failure isolation, MDX/Kroki caching, SVG sanitization, prompt-injection posture, Policies/IDOR, pagination, auth pinning, observability, UX states) applied to SPEC.md Rev 2.
Decision log (self-hosting pivot, 2026-07-01 — SPEC Rev 3)
- ✅ Self-hostable distribution: AGPL-3.0 public repo, full feature parity (demo mode SaaS-only), Docker Compose + Caddy single-origin reference deployment, new M7 milestone. Self-hosting replaces the enterprise-SaaS trust roadmap (WorkOS SSO/SAML/SCIM dropped → generic OIDC in Later); application security (§13) unchanged.
- ✅ Consequence — rendering shell swap: Tailwind Plus Protocol code can't ship in a public repo → Fumadocs (MIT) styled to the Protocol aesthetic; Protocol demoted to design reference. M0 spike validates the fit (fallback: Nextra).
- ✅ Consequence — PAT connector is permanent (self-host primary path); previously scheduled for deletion at M6.
- ✅ Consequence — Nova is optional (SaaS-ops only), never a runtime dependency of the open-source app.
Open TODOs (pre-implementation)
-
P1 (S)— done 2026-07-03: 6 variants explored; "Protocol Rebuild" approved (clean-room Protocol aesthetic). Design language codified in/design-explorethe comment gutterdocs/DESIGN.md; canonical mockupdocs/designs/review-page.html; losing variants deleted. -
P1 (S) Spike: port hypothes.is anchoring— done 2026-07-20 (M3): exact→fuzzy→orphan ladder shipped (#76/#77) on@sanity/diff-match-patch, golden-corpus-validated. -
P1 (S) M0 spike: validate Fumadocs— done 2026-07-03: VALIDATED (web/in repo, runnpm run dev). Fumadocs 16 + Next 16 renders.mdand.mdx; DESIGN.md tokens applied via--color-fd-*overrides; system fonts + dark default work; right TOC column swapped for a static review rail — the comment-gutter layout is feasible. Dogfooding: SPEC/DESIGN/TODOS render as content copies. Findings for the real build: (a) unknown fence languages (plantuml) crash Shiki —langAliasworkaround insource.config.ts; product needs a never-crash fallback for arbitrary fence langs (add to §19 failure modes at M1); (b) content needs frontmatter — the ingestion pipeline must synthesizetitle/descriptionat import; (c) M2 work identified: sticky/anchor-aligned rail, sidebar "Threads" nav group. -
Diagram rendering spike— done 2026-07-03: VALIDATED, then revised same day. Final state: Kroki is the sole diagram engine (decision below).remark-diagramsconverts any fence on the engine allowlist (~20 engines) →<KrokiDiagram/>before Shiki, for both.mdand.mdx; async RSC → Kroki GET (deflate+base64url), fetch-cached,KROKI_URLoverride, SVG via<img data:>(no script surface), skeleton + show-source error state. Verified live: Mermaid, PlantUML (incl. SPEC.md's architecture/sequence/ER diagrams), and an Excalidraw sketch. Findings: (a) complex diagrams shrink to fit — click-to-zoom is genuinely needed, build at M1; (b) product needs the R2-backed SVG cache keyed by source hash (§6.2) — Next fetch cache is per-build, not shared.
Decision log (diagram engine, 2026-07-03)
- ✅ Kroki is the sole diagram engine (SPEC §6.2 rewritten). Supersedes the client-Mermaid + Kroki-PlantUML split. Rationale: one code path for ~20 engines (incl. Excalidraw + PlantUML — the full sketch-to-precise spectrum), deterministic version-cacheable SVG, no mermaid ESM in the reader bundle, no in-browser diagram-parser XSS surface. Condition attached: Kroki self-hosted in both editions from M1 — kills the third-party privacy leak that originally justified client-side Mermaid. Explicit engine allowlist; unknown fence langs fall through to plain text.
-
P2 (S) Decide web-side error reporting— done 2026-07-11: deferred to Launch (Import & render spec). Demo mode is SaaS-only and the SaaS has no public traffic until go-live; anything adopted must be off/optional in self-host builds. Revisit in Launch's tail. -
P2 (S) Pick the name/domain— done 2026-07-09: Kedge (see decision log below).
Decision log (naming, 2026-07-09)
- ✅ Product named "Kedge" /kɛdʒ/. Rationale: kedging = moving a ship forward by repeatedly re-setting an anchor — names the moat itself (comments re-anchoring across versions); 5-letter CLI-friendly; unique in search; best availability of all candidates checked. Runner-ups: Stet, Manicule. Availability verified 2026-07-08 via RDAP/whois: kedge.review and kedge.ink unregistered; kedge.md likely free; bare
github.com/kedgetaken → org kedgehq. Repo, docs, web app, and dogfood copies all carry the name. - USER ACTION P1 (S): register
kedge.review+kedge.ink(andkedge.mdif free); create thekedgehqGitHub org. - USER ACTION P1 (S): run a proper trademark search (USPTO/EUIPO) on "Kedge" for software/SaaS classes before public launch — note KEDGE Business School (France) exists in a different class.
- P2 (S) Logo pass: forward-tilted kedge-anchor mark per DESIGN.md Brand section.
- P2 (S) CLA/DCO decision before accepting the first external contribution (SPEC §22.6).
- P3 (S) Write the register-your-own-GitHub-App guide for self-hosters (lands with M6/M7).
Decision log (web production build, 2026-07-13)
- ✅
web/build script forcesNODE_ENV=production(#14).next buildonly defaultsNODE_ENVtoproductionwhen it is unset; an ambientNODE_ENV=development(common in dev shells / some Docker base images) leaks through, so Next bundles React's development build. Prerendering the synthesized/_global-errorpage against dev React then crashes withCannot read properties of null (reading 'useContext'), failing the build. Root cause is the env, not app code — reproduced on a bare, single-page Next app and unaffected by a customglobal-error.tsx, the bundler (webpack fails identically), or patch bumps. Fix:"build": "NODE_ENV=production next build"makes the build immune to the ambient value. CI's web job now runs the build (it previously only type-checked, so this was latent). Also added a provider-independentapp/global-error.tsxas a branded, self-contained crash surface.
Decision log (M1 import tracer bullet, 2026-07-13)
- ✅ Raw-URL import spine shipped (
#17, first slice of M1).documents+document_versionsper SPEC §16 (backed enumsDocumentStatus/LifecycleStatus/SyncStatus/DocumentFormat/SourceType,(document_id, content_hash)unique, un-constrainedcurrent_version_idpointer — a hard FK there would be circular withdocument_versions.document_id).POST /api/v1/documents→ Policy →importing→ImportDocumentJob(ShouldBeUnique) → 202;GET /documents/{id}polls;POST /documents/{id}/retryfor the §19 failure CTA. Blocked URLs (SSRF guard) fail the import immediately with no retries — deterministic, unlike transient timeouts/5xx which retry ×3 with backoff. - ✅
Connectorinterface extended withsourceType(): SourceTypebeyond SPEC §5.1's four methods, so each connector owns itsdocuments.source_typevalue and the registry stays additive for GitHub/Confluence (#22/#23).integration_idcolumn exists (nullable, no FK) for the PAT connector. - ✅ Interim markdown renderer (
web/lib/render-markdown.tsx): unified + remark-rehype withoutallowDangerousHtml(raw HTML/<script>dropped, never parsed) + an href/src scheme allowlist (stripsjavascript:links), output as real React elements (nodangerouslySetInnerHTML). This is the deliberately-simple safe path — the hardened MDX pipeline (#20) and Kroki diagrams (#21) replace/widen it. Unknown fence languages render as plain<pre><code>(no highlighter to crash), satisfying the never-crash rule early. - ✅
DocumentResourceis unwrapped ($wrap = null) to match M0'sCurrentUserResourcehouse shape;AuthorizesRequestsadded to the baseControllerso every resource controller can$this->authorize().
Decision log (M1 normalization, 2026-07-14)
- ✅ Normalization shipped (
#19, SPEC §5.2). Lives in its own serviceApp\Services\Import\Normalization(Normalizerorchestrator →HtmlNormalizer+ImageReHoster+SvgSanitizer), invoked fromDocumentImporterat one call site so#18's projection call stays a clean additive edit..html(by content type or URL extension) → sanitize → markdown; everything else passes through as markdown. Nothing throws on bad content: HTML that won't convert degrades to recoveredstrip_tagstext + warning; images that won't fetch keep their original URL + warning. - ✅ Sanitizer choices.
symfony/html-sanitizer(safe-element allowlist) for the HTML→markdown pre-pass — drops<script>/<style>/<iframe>, every event handler and inline style, andjavascript:URLs before league/html-to-markdown (+ itsTableConverterfor GFM tables) ever sees them.enshrined/svg-sanitizefor SVG bytes — SVG is namespaced XML with its own script surface, so a maintained SVG-aware sanitizer beats a hand-rolled allowlist and beats the HTML sanitizer (which would gut the drawing). Both are maintained; no proprietary code. - ✅ Image re-hosting →
MEDIA_DISK(env, defaultpublicserved via the/storagesymlink; S3/R2 on SaaS). Every referenced image is fetched through the existingGuardedFetcher(SSRF guard reused, not re-implemented), stored content-addressed atmedia/{documentId}/{sha256}.{ext}(idempotent re-imports, stable doc hash), URL rewritten in the normalized markdown. SVGs are sanitized before storage. - ✅ Import warnings persist on the version. New nullable JSON
document_versions.import_warnings(a property of the snapshot, not the document identity);{type, message}shape, hand-mirrored toweb/lib/document-types.ts. Every failed image fetch / degraded conversion is a warning; the import continues (SPEC §19). Surfaced author-facing as a collapsible amber<details>panel on the doc page (web/components/app/import-warnings.tsx, DESIGN.md callout anatomy, one-line mount).
Decision log (M1 relative-link resolution, 2026-07-15)
- ✅ Relative link hrefs absolutize against the source (
#50, SPEC §5.2). NewLinkReWriterpass in theNormalizerpipeline, besideImageReHosterand reusing the sameGuzzleHttp\Psr7\UriResolver(RFC 3986) resolution against the connector'sfinalUrl— the human blob URL for GitHub (so[x](./other.md)→ a sibling blob URL Kedge can itself import — the intended loop), the post-redirect URL for raw sources (→ raw sibling). Handles inline links[t](url)and reference-style definitions[t]: url(both survive intocontent_normalized; the importer stores markdown verbatim), preserving titles and<…>-wrapping like the image pass. Untouched: absolute/mailto:/tel:(any scheme), protocol-relative//host, pure fragments#…(future in-page anchors), and pasted/uploaded docs (empty base → no-op). Root-relative/xresolves against the source origin (documented RFC-3986 choice). - ✅ Composes with image re-hosting by construction, not coordination.
LinkReWriterruns afterImageReHosterand the two never overlap: images match only(anchored on!), links match only[…](…)(negative lookbehind on!) plus[…]: …definitions. The badge idiom[](./page.md)composes — the inner image is rehosted first and carried through verbatim while only the outer href is absolutized (the link text group admits a whole nested image; an atomic group forecloses catastrophic backtracking on untrusted input, hard rule #2). Order is therefore immaterial to correctness. Kedge-internal linking to an imported target is explicitly not attempted (post-v1 Project concept, CONTEXT.md).
Decision log (M1 MDX security pipeline, 2026-07-14)
- ✅ Hardened MDX render path shipped (
#20, SPEC §6.1).@mdx-js/mdxcompiles imported MDX in the web server layer; the compiled artifact is cached by content hash.lib/remark-mdx-harden.tsruns on the mdast afterremark-diagramsand is the security gate: rejectsimport/exportand non-literal expressions (incl. expression-valued + spread attributes — closing the attribute-channel injection thatunist-util-visitnever descends into), allowlists JSX toCallout/Note/Warning/CodeGroup/Tabs/KrokiDiagram, rewrites unknown components to a neutral<UnsupportedComponent>box, and sanitizes intrinsic HTML..mddocs keep the#17renderer (raw HTML dropped) unchanged; the doc page + share page branch through one sharedDocumentBody. - ✅ rehype-sanitize cannot govern MDX — architecture deviation from SPEC §6.1's literal wording. In MDX every angle-bracket construct (a
<Callout>and a raw<script>) parses to anmdxJsx*Elementnode, never a hastelement/rawnode;hast-util-sanitizeonly handles element/text/comment/root and silently drops every other node type — verified: droppingrehype-sanitizeinto the pipeline deletes the allowlisted components too. So the tight schema is enforced in the harden plugin at the mdxJsx level, reusingrehype-sanitize'sdefaultSchema(tag/attribute/protocol allowlists) as the schema source of truth — "raw HTML sanitized against rehype-sanitize's tight schema" holds in substance, not by wiring the plugin into a tree it would gut. SPEC §6.1 amended to record the mechanism + rationale. - ✅ Compile cache = in-process LRU + on-disk compiled-source layer (
lib/mdx.tsx). A compiledMDXContentis a live closure (unserializable), so L1 (LRU of the run outcome, keyed bysha256(content)) is where "render per request without recompiling" happens — shared across requests because Next is a long-lived process, not a per-build cache. L2 caches the compiled JS source string (compile()output, beforerun()) on disk (MDX_CACHE_DIR, default tmp) so the expensive parse+transform survives cold starts and is shared across workers on a host; best-effort, never holds failures, any disk error falls through to recompile. Key derived from content (equivalent to the API'scontent_hash), so validation-at-import warms the cache render-at-request reuses. - ✅
mdx_okis real and stored (additive nullabledocument_versions.mdx_ok). The projection endpoint runs the same hardened compile as render to decidemdx_ok(the one definition of "what this document is", SPEC §5.4/§6.1): null for non-MDX, true = compiled, false = rejected/uncompilable. The importer passes the freshly detected format (not the creation-timemddefault) to the projector — the bug that would otherwise skip MDX validation entirely — storesmdx_ok, and logsmdx.compile_failedon false..mdxis detected at import by URL/filename extension (Normalizer). A falsemdx_okis a fallback, not an import failure: the doc isreadyand renders plain markdown + banner. - ✅ Spike
langAliasworkaround deleted (web/source.config.ts). Replaced the special-caselangAlias: { plantuml: 'txt' }with fumadocs' generalrehypeCodeOptions.fallbackLanguage: 'plaintext'— every unknown fence language falls to plain text, not one hard-coded case (SPEC §6.2 / §19; resolves the#17spike finding). Dogfood/docs(incl.rfc-017-anchoring.mdx+ SPEC's plantuml/mermaid fences) verified still rendering.
Decision log (M1 GitHub PAT connector, 2026-07-14)
- ✅ Private GitHub import via encrypted per-workspace PAT shipped (
#23, SPEC §5.1/§13/§16 — the permanent private-source path, SPEC Rev 3).integrationstable (providerbacked enumIntegrationProvider,github_patonly at M1;credentialsencrypted:arraycast; nullablemeta). EndpointsGET/POST/DELETE /api/v1/integrationsbehindIntegrationPolicy— list/connect are scoped to the caller's personal workspace (cross-workspace reach is structural, not just policy-checked), delete resolves a row by id and is policy-guarded. Rate-limited (throttle:integrations, 15/min/user) on the mutations. - ✅ Credential hygiene is layered (SPEC §13): the token is (a)
encrypted:arrayat rest, (b) on the model'sHiddenlist so serialization can never leak it, (c) absent fromIntegrationResource(an independent allowlist), and (d) never logged — the connector sends it only on the outboundAuthorizationheader (curl transport, not logged) and the guard already redacts URLs. The masked listing shows at most a last-4 hint stored inmetaat connect time, so a normal read never decrypts the token. Proven by tests (serialization, raw-column ciphertext, log scan) and a live curl transcript. - ✅ Shared GitHub parsing extracted to
AbstractGithubBlobConnector(blob-URL → contents API, raw media type, SSRF pin, §19 rate-limit back-off);GithubPublicConnector(unauthenticated) andGithubPatConnector(Bearer token) differ only in auth headers and auth-failure handling. The PAT reader is registered after the public one so plainmatch()never returns it blindly — it is reached only by source type (the job) or byConnectorRegistry::preferredMatch(), which upgrades a public GitHub match to PAT whenever the workspace has a token (reads private repos and public ones on the higher authenticated budget; raw/upload never upgraded). - ✅
documents.integration_idrealized as an indexed soft reference (no FK/cascade), matching the un-constrained-pointer precedent ofcurrent_version_id: removing a PAT must never delete the documents already imported with it (their versions are immutable). The bound integration is recorded at import creation and passed to the connector via a new optionalDocumentSource::integrationId. - ✅ Token-revoked path (SPEC §19 registry row): a
401on a PAT-authenticated fetch → terminalTokenRevokedException(handled like a blocked URL — markedfailedat once, not retried),sync_error = "GitHub token was revoked or lacks access — reconnect the integration.". The web import-failed state matches on "reconnect" to surface a Reconnect GitHub link to/settingsalongside the retry CTA. - ✅ Web integrations surface: a
/settingspage in the authenticated shell (linked from the header) hosting anIntegrationsPanelclient island — a password-type PAT input (never echoed back), the masked••••<last4>listing, and remove. Same Sanctum SPA + XSRF pattern and DESIGN.md tokens as the share panel;web/lib/integration-types.tshand-mirrors the resource (accepted TS/PHP-duplication debt).
Decision log (M1 instant demo mode, 2026-07-14)
- ✅ Instant demo mode + claim shipped (
#25, SPEC §10.3, SaaS-only). UnauthenticatedPOST /api/v1/demo/documents {url}reuses the whole import spine (queued job → normalize → project → version), constrained to public connectors only (github_public+raw_url; a URL no public connector claims 422s), aggressive per-IP rate limits (throttle:demo= burst-per-minute + per-day, both config, tuned at Launch), and the SSRF guard inherited free from the sharedGuardedFetcher. The whole surface is edition-gated by anEnsureDemoModeEnabled(demo.enabled) middleware that 404s per request whenSELF_HOSTED=true— same request-time toggle pattern as the GitHub OAuth routes, so a test flips the edition without rebuilding the route table. - ✅ Demo docs live in a reserved system workspace,
created_bynull. A dedicatedSystemWorkspaceservicefirstOrCreates one workspace markedsettings.system=true(not a slug/fixed-id — a user could be named "Kedge System", but no registration path ever sets that marker, so it can't collide), memoized as a singleton becauseDocument::isDemo()(system-workspace membership — the canonical "is a demo doc" signal) runs on every shared-doc read. Chosecreated_by = nullover a synthetic system user: the importer is genuinely anonymous, and a fake user would polluteusers+ audit attribution; the column was already nullable and the whole import path already tolerates a null creator.expires_at = now + DEMO_TTL_HOURS(48h). - ✅ Claim (
POST /api/v1/documents/{id}/claim) is Policy-authorized (DocumentPolicy::claim=isDemo()), deliberately not a membership check — the doc isn't yours yet. It moves the doc into the claimer's personal workspace, sets them as creator, and clears the TTL; versions + shares ride along untouched, so the anonymous link the visitor already holds keeps working (now read-only, no longer claimable). A normal or already-claimed doc both 403 (no longer demo) — first come, first owned. Anonymous probes hit theauth:sanctum401 before the demo 404 (auth runs first); the authenticated self-hosted case correctly 404s. - ✅ Share-link surface reused for the demo view, not a parallel route. The demo response mints an anonymous share (
ShareLinkService::issuecreator made nullable) and returns its URL; the visitor lands on/shared/{token}.SharedDocumentResourceexposesclaimablefor every share and, only when claimable, thedocument_id+expires_atthe claim flow needs — the one deliberate exception to the surface's "never expose the internal id" rule, justified because a demo doc is a public throwaway whose entire purpose is to be claimed. - ✅ Web root
/moved out of the(app)route group so it escapes that group's auth guard and owns three branches itself (signed-in review queue in the extracted<AppShell>· anonymous SaaS demo paste box · anonymous self-hosted → sign-in). Edition read from the newself_hostedboolean onGET /api/v1/config(single source of truth = API env; web carries noSELF_HOSTEDvar), failing closed to self-hosted so the public demo surface never shows on an instance we can't confirm is the SaaS. Claim completes client-side at/documents/{id}?claim=1(the doc is still in the system workspace, so it must claim before the authenticated read). The M0 Playwright journey's post-signout step updated: the anonymous home is now the paste box, so a guarded doc route is what proves session invalidation. - ⏭️ Demo-specific fetch size cap deferred. Demo fetches are capped by the shared
FETCH_MAX_BYTESconfig ceiling (the "capped sizes" abuse control, SPEC §13); a tighter per-demo byte cap would need the import pipeline to thread a per-document cap through the connectors/GuardedFetcher, so it is deferred to Launch alongside the rate-limit tuning (ROADMAP "Not yet specified"). The rate limits + public-connectors-only + 48h TTL + SSRF guard are the substantive, tested abuse controls. (S) - ✅
kedge:prune-demo-docsscheduled hourly (routes/console.php, registered only when not self-hosted) reaps expired unclaimed demo docs; versions + shares cascade at the DB. Scoped to the system workspace + non-null pastexpires_at, so it can never touch a claimed or ordinary doc, and it is a clean no-op on an instance that never created the system workspace. Events:demo.created/demo.claimed/demo.pruned.
Decision log (M1 Kroki diagrams, 2026-07-14)
- ✅ API-mediated render cache shipped (
#21, SPEC §6.2). New internal endpointPOST /api/internal/diagrams(outside/v1, not a public contract) →App\Services\Diagrams\DiagramRenderer: renders each diagram once per (engine, sha256(source)), caches the SVG atdiagrams/{engine}/{hash}.svgonMEDIA_DISK, returns the/storageURL. The cache is content-addressed and NOT document/version-scoped, so the same diagram re-used across documents and across a document's versions renders once — every later request is a disk hit that never calls Kroki. The web's diagram component fetches only the cached URL; readers load a static<img>and execute zero diagram code (replaces the spike's per-build Next fetch cache, which isn't shared). - ✅ Kroki call bypasses the SSRF guard — deliberate. Kroki is a trusted, operator-configured internal service (
KROKI_URL;http://kroki:8000in the self-host compose), soDiagramRendereruses the plain HTTP client with size/timeout caps, notGuardedFetcher(whose https-only + private-IP blocking would reject the internal container). GET payload encoding is zlib (gzcompress, RFC 1950 — matches Node'szlib.deflateSync), not the header-lessgzdeflate, which Kroki's pako inflate rejects. - ✅ SVG stored as Kroki emits it; the
<img>embedding is the security boundary (SPEC §6.2: "<img>/ sanitized"). Deliberately does NOT runenshrined/svg-sanitize(which is for arbitrary uploaded images,ImageReHoster): Kroki is the sole trusted renderer (hard rule #3), and its mermaid output carries node labels in<foreignObject>HTML — the exact element the SVG sanitizer strips, which blanked every mermaid label (caught in live verification, not by fixtures — the dogfooding principle earning its keep). Readers embed the SVG via<img>, where a browser runs no script and fetches no external resource, so the diagram-parser XSS class is eliminated regardless of source; the<img>boundary is the guarantee, not a post-hoc scrub. - ✅ Internal endpoint guard mirrors projection, roles swapped.
VerifyDiagramSecretmiddleware requiresX-Diagram-Secret(DIAGRAM_SHARED_SECRET), 404s a bad/missing secret (existence stays hidden), and fails closed in production when unset (dev falls back to the shared well-known value the web also defaults to). Engine allowlist is enforced a second time on the API (DiagramEngines, the PHP mirror ofweb/lib/remark-diagrams.ts) so a leaked secret can't turn it into an open Kroki proxy; unsupported engine → 422, never forwarded. - ✅ One allowlist drives three call sites.
web/lib/remark-diagrams.tsdiagramEngineForremains canonical for fence detection and the projection's⟦diagram:engine⟧tokens; the API'sDiagramEngines::ALLmirrorsKROKI_ENGINES(hand-written TS/PHP duplication, accepted M1 debt). The web resolves aliases (dot→graphviz, …) before ever calling the API, so only canonical engines cross the boundary. Unknown fences still fall to plain text, never to Kroki. - ✅ Two diagram components, one presentation — dogfood build-time vs. imported request-time.
/docsis statically generated (SSG) where the API isn't running, so it keeps its build-time component (components/kroki-diagram, direct Kroki fetch → inlineddata:SVG; readers still never contact Kroki). Imported docs are dynamic (request-time), so they use the API-mediated component (components/cached-kroki-diagram), bound to the JSX nameKrokiDiagramin the imported-MDX allowlist and to<kroki-diagram>in the markdown renderer (remarkDiagramsHast— the.md/.html/MDX-fallback path had rendered diagram fences as plain code until now). Both shareDiagramFigure(the<img>+ click-to-zoom lightbox: accessible dialog, ESC/overlay close, scroll lock, DESIGN.md tokens) and the never-crashDiagramSourceErrorpanel. Unifying onto the API path was rejected: it would either regress the SSG build to error panels (API down at build) or force/docsdynamic (losing SSG) — the SVG-acquisition strategy is inherently build-time vs. request-time, while the allowlist and visual treatment stay unified. - ✅ States + observability. Loading skeleton is the Suspense fallback on the imported path (the only genuinely async diagram path — the API round-trip streams per diagram); a Kroki/bad-source failure →
kroki.render_failedlog event (SPEC §19) + the show-source panel; a cache-miss render logskroki.rendered(info) so the cache-hit proof is a log count, not a guess.sourceadded to theTrimStringsexempt list (withcontent) so whitespace-sensitive engines (svgbob/ditaa) and the cache key stay byte-faithful. - ✅ Renderer error surfaced beside the source (
#56, 2026-07-15). A failed diagram now shows Kroki's own reason under a "Renderer said:" label between the error chip and the source, so authors fix the diagram without leaving the page. The reason is untrusted text (Kroki parses untrusted source): the API sanitizes it once — collapse whitespace, strip control chars, hard-truncate to 500 — returns it asdetailin therender_failed422 (shape unchanged when absent), and logs a short form; the web escapes it on render (an injected tag shows as literal text). Network failures get a genericdiagram service unreachable— the low-level message stays in the log, never on the page. Decision: the API's Kroki GET drops theAccept: image/svg+xmlheader — the/svg/path already fixes the success format, while requesting SVG made Kroki answer failures with an SVG "error card" whose reason is buried in<text>markup (and truncated away); omitting Accept yields the plain-text reason (e.g. mermaid "No diagram type detected"). The dogfood build-time path sanitizes Kroki's body itself (sharedlib/diagram-detail), since it has no API in front of it.
Decision log (M1 Playwright journey, 2026-07-15)
- ✅
FETCH_ALLOW_HOSTS— a test-only escape hatch through the SSRF guard (#26, reused by#39). The journey's pasted URL must be deterministic (CI never fetches a live external URL), but the guard rightly blocks loopback — so a comma-separated host allowlist (empty default) exempts exactly the listed hosts from the private-address rejection, with plain http additionally permitted for them (fixture servers run loopback HTTP; the transport now pins curl's wire protocol to the vetted scheme instead of hard-pinning HTTPS). Everything else still applies — redirects off an allowlisted host re-run the full guard, size/timeout caps unchanged. Set only in the generated E2E env (web/e2e/serve-api.sh); documented as never-for-production inapi/.env.example; off-by-default and per-host proven by guard tests. A dedicated loopback fixture server (web/e2e/serve-fixtures.mjs, dependency-free, booted as a third PlaywrightwebServer) was chosen over stashing fixtures inweb/public/so test content never ships in the deployable. - ✅ CI renders diagrams through its own Kroki service containers —
yuzutech/kroki(pinned) + thekroki-mermaidcompanion it delegates mermaid to, wired by service name on the Actions network;KROKI_URLflows throughserve-api.shinto the generated.env.e2e. CI never calls hosted kroki.io; a local run keeps the dev default (kroki.io) unlessKROKI_URLis exported. The journey proves the render end-to-end:<img src>asserted against the content-addressed/storage/diagrams/mermaid/<hash>.svgcache andnaturalWidth > 0(the SVG actually decoded), with the fence source asserted absent from any<pre>. - ✅ The E2E API runs its queue
sync— imports execute inline in the demo POST instead of on a database workerserve-api.shwould have to babysit (extra process, SQLite write contention, teardown lifecycle). The journey asserts user-visible outcomes; the async 202→poll lifecycle stays covered by the API feature tests.PROJECTION_URLpinned to IPv4 for the samelocalhost→::1reason as the rest of the e2e host choices. - ✅
serve-api.shdotenv-ordering bug fixed — overrides go below the example. The M0 script put overrides at the top, commented "first-occurrence-wins"; Laravel's dotenv actually lets a later line in the same file overwrite an earlier one (only pre-existing real env vars are immutable). Harmless at M0 (no override conflicted with a differing uncommented example line —APP_ENVwon via the serve passthrough), but it silently swallowedQUEUE_CONNECTION/PROJECTION_URLat M1. Caught by this journey's first run — the dogfooding principle paying out again.
Decision log (E2E journey pack, 2026-07-15)
- ✅ Testing philosophy changed: one-journey-per-module → a journey PACK per shipped surface (
#39, decided at the 2026-07-14 review). The one-journey discipline was right while surfaces were being built (a single tracer per module, no suite to rot); with M0+M1 shipped, every real flow is now worth locking down in a browser. Nine new specs join M0+M1 (15 tests, 11 files):auth-edges(wrong password / duplicate email / anonymous deep-link→signin?next=→land-back),import-url,import-paste,import-failure,import-warnings,mdx-safety,share-lifecycle,diagrams,settings-pat. The affected Testing sections ofdocs/specs/m0-scaffold.md(auth surface) anddocs/specs/m1-import-render.md(everything else) are amended to match. - ✅ Worker-safety is a property of the specs, not the config. Every spec registers its OWN per-run + per-spec unique user (
e2e/helpers.tsuniqueIdentity) and creates its OWN documents — no fixture user, no shared doc, no order dependence.playwright.config.tskeepsworkers: 1/fullyParallel: false(a scope choice: flat CI resource use, deterministic output), but the pack is proven green at--workers=4and--workers=4 --fully-parallellocally, so raising it later is a one-line change. Shared steps and the fixture-URL registry live ine2e/helpers.ts+e2e/fixtures.ts; new fixture documents (a plain.md, a diagram.md, a warnings.html+ a 1×1.png, two.mdx) undere2e/fixtures/, and a stateless/status/500route added toserve-fixtures.mjs(traversal-guarded, dependency-free) for the transient-failure and broken-image journeys. - ✅
CACHE_STORE=arrayadded to the E2E env (serve-api.sh). The pack drives many unique users over one loopback IP, and the cache-backed rate limiters (throttle:auth10/min/IP,imports/shares/integrationsper user-or-IP —AppServiceProvider) key on values that are shared or clustered across independent journeys; with the example's persistentdatabasestore their counters would couple otherwise-isolated specs (a limit tripped in one 429s an unrelated one) and cluster the auth bucket past its ceiling when workers are raised.arrayis per-request underphp artisan serve(each request bootstraps fresh), so the counters never persist. Nothing else in the app reads the cache (the diagram cache is on the media disk), and rate-limit behaviour keeps its own API feature tests — so this only removes cross-journey interference, never a real assertion. - ✅
import-failureis a deliberate split — fail-only, not retry-flip — because of the sync-queue seam (the ticket's escape hatch, taken with cause).import-failurecovers two modes: an SSRF-blocked URL (deterministic → the import job swallows it, marks the doc failed inline, so the API answers 202, the browser reaches the failed document page, and the friendly "URL not allowed (private address)." message + a working Retry CTA render) and a transient upstream 500 (→ a friendly inline error on the form, no crash). The specced "retry-flip → retry succeeds" journey is not reproducible in this seam: the E2E API runsQUEUE_CONNECTION=sync, so a transient failure — unlike a swallowed block — rethrows out of the synchronousdispatch()insidePOST /documents, the request returns 500, and the (created + failed) document is never reachable through the UI to click Retry. A retry-that-succeeds needs the async (database) queue production runs, where 202 returns before the import completes; under that driver the flip is real and it is covered by the API feature tests (DocumentImportTest). Reproducing it here would mean running a queue worker as a fourth process and abandoning the deterministic sync seam the whole E2E design rests on. - ℹ️ Minor product observation (not this pack's to fix). Retrying a permanently blocked URL leaves the
ImportFailedisland's "Retrying…" pending flag un-reset (the island persists acrossrouter.refresh()when the doc re-fails, and the success path returns without clearing it). Harmless — it never appears on the normal retry-then-succeed path, where the panel unmounts — and confined to the unusual act of re-retrying a deterministically-blocked import. Noted in the PR; theimport-failurespec asserts the deterministic failed state, not the button label.
Known debt (accepted deliberately)
-
Image re-hosting covers inline markdown images (
) — what native markdown and the HTML→markdown pass produce; reference-style image definitions and surviving raw<img>tags are not re-hosted (the renderer drops raw HTML anyway). (S) -
Relative-link absolutization (
LinkReWriter,#50) is a pragmatic regex over the markdown, same altitude asImageReHoster: link text with unbalanced brackets, and links/definitions written inside fenced code blocks, are out of reach. A full CommonMark parse would close both but was deliberately not taken on for either pass. (S) -
SVG re-hosting: a namespaced
xlink:hrefremote reference on<image>can surviveenshrined/svg-sanitize'sremoveRemoteReferences(library quirk), but it is inert when the asset is rendered via<img src>; the XSS-critical surface (scripts, handlers,javascript:) is always stripped. Revisit if SVGs are ever inlined. (S) -
Polling inbox → SSE/Reverb later. (S)
-
SaaS uses hosted kroki.io → move SaaS to self-hosted Kroki later (
KROKI_URL; the self-host compose already bundles it). (S) -
TS/PHP type duplication across the API boundary → OpenAPI codegen. (M)
-
Re-anchor matcher per-thread timeout budget; revisit if docs exceed ~200 threads. (S)
-
PAT connector stopgap → delete at M6— reversed in Rev 3: PAT is a permanent, supported connector (self-host primary path).
Deferred features (post-v1)
- Raw comment sync-back to GitHub/Confluence (digest post-back shipped instead)
- Team workspaces UI · generic OIDC SSO for both editions · IP allowlists/retention (schema is ready; WorkOS/SAML/SCIM dropped — self-hosting is the enterprise trust answer)
- Generic git connector (GitLab/Bitbucket/self-hosted) —
Connectorinterface accommodates it - Slack notifications · reply-by-email via Postmark inbound
- Realtime comments + presence (Laravel Reverb)
- Required reviewers + review deadlines (approvals lite shipped instead)
- Review analytics · RFC index (draft→accepted→superseded) — 12-month ideal
- localStorage draft persistence is IN scope (M2); listed here previously — moved.
Decision log (domain model: projects, sources, PRs, 2026-07-15)
- ✅ A PR is a candidate version of existing documents, never a separate document — ADR 0001. Decided ahead of M3 so the version-lineage schema is designed as lineage-with-candidates from the start. SPEC untouched (no v1 scope change); amend SPEC §7/§16 when M3 is specced.
- ✅ Organization language pinned in CONTEXT.md (new root glossary, via /domain-modeling): Project = free container in a workspace (post-v1); Source = where content comes from — a repo is a source, never the container; issues/external links attach as References, never import. Roadmap fog carries the post-v1 scheduling.
Known debt (added at M2 eng review, 2026-07-15)
- Rail virtualization: anchor-aligned thread cards render unvirtualized; past ~200 threads the DOM cost hits the same wall as the re-anchor matcher budget (existing debt line). Revisit when a real document gets that busy. (M)
Known debt (added at M3 eng review, 2026-07-20)
-
Candidate lineage lands unexercised. M3 adds the candidate-capable version schema (
document_versions.kind+parent_version_id, ADR 0001) but only the mainline path runs; candidate creation and re-anchoring a candidate into the document's lineage arrive with the M6 PR-URL connector. A forward-compat schema that's never exercised can be subtly wrong — M6 must add the candidate-path tests, not just the connector. Where to start: thekind/parent_version_idcolumns + the version-lineage ordering M3 ships. (S) -
Version labels are global DB ids, not per-document ordinals (#75 review). The header/roster/switcher render
v{document_version.id}(e.g. "approved v4821 · current v4830") instead of the spec's per-document "v3 · current v5". Web and api are consistent with each other, so it's cosmetic, but it reads poorly. Fix in #78 (version switcher) by deriving a per-document version ordinal (position in the lineage) for display, reused by the roster + diff header. (S) -
Reviewer-surface version switcher + banner deferred (#78). The authenticated member/author review page gets the version switcher + new-version banner; the shared reviewer surface does not (the version endpoints are member-scoped via DocumentPolicy::view). SPEC §7 implies reviewers land on latest and switch versions — wiring it needs reviewer-via-share version authz + embedding the ordinal-labelled version list in SharedDocumentResource (server-rendered per ?version= on the shared route). Add when the reviewer version-viewing experience is prioritized, or during the M3 e2e wrap. (S)
Known debt (added at M4 eng review, 2026-07-20)
- Per-agent display identity on comments — agent comments render owner-name + generic
AGENT · MCPbadge; a user running two agents can't tell their comments apart. The token name is known at auth time (currentAccessToken()->name) and the audit log records it per MCP write, so no data is lost — only rendering is deferred. Where to start: acommentsmeta/agent_namecolumn + a badge variant. Revisit when multi-agent workspaces are real. P3 (S) - Workspace AI spend cap —
throttle:ailimits request rate, not spend; a chunked digest costs many× a normal run, and on the SaaS one workspace could burn the shared-key budget. Theai_runsledger records cost per run, so enforcement is a config ceiling +SUM(cost)guard in the run-creating endpoints. Same tune-at-Launch class as demo abuse thresholds (real numbers need real traffic). P2 (S) - Dedicated AI queue — AI jobs (10–60s, minutes when chunked) share the single database queue with import/re-sync; a digest burst could delay a re-sync (the core promise). One-line
onQueue('ai')+ a second worker when needed, but that complicates the M7 compose story — measure queue wait inai_run.startedlogs first, act on evidence. P3 (S)
Open TODOs (from M2 #64 magic-link review, 2026-07-16)
- Per-share
allow_anonymoustoggle — SPEC §16 listsallow_anonymouson thesharesentity and §10.2 promises "anonymous commenting off by default with a per-share toggle." The column was never added (M1) and #64 shipped magic-link-required only. Add the column + the anonymous-comment path behind it when a real need appears; magic-link-required is the safe default meanwhile. (S) - Passwordless-reviewer ↔ real-account reconciliation — a reviewer shadow user (passwordless, non-member) created before signup now upgrades in place on
/register(#64 fix C1). Revisit when a password-reset/claim flow lands (none exists yet) so a reviewer can also claim their shadow account via reset, not only fresh registration. (S)
Decision log (M2 #64 reviewer identity, 2026-07-16)
- ✅ Reviewer identity is a distinct, share-scoped principal — a passwordless, non-member User bound to one Share via
share_participants; magic links NEVER authenticate a credentialed or workspace-member account (that path requires normal sign-in). Terms added to CONTEXT.md (Reviewer, Share Participant). - ✅ Magic-link verify is two-step — a safe/idempotent GET validates the signed link and hands a short-lived completion token to the web app, which completes login via a same-origin XSRF-protected POST in the Sanctum stateful session. This is what makes the reviewer session work over real HTTP while closing login-CSRF and mail-scanner-prefetch (the GET consumes nothing; the POST is single-use). Caught by curl smoke; the definitive regression is the #68 e2e.
Known debt (M2 #68 e2e mailbox helper, 2026-07-16)
- The e2e magic-link mailbox helper (
web/e2e/mailbox.ts) scopes to a fixed ±char window around the recipient email and only truncates the log at server boot (not per test). Both hold today via per-run unique emails; revisit (larger/again-computed window, per-test truncation) if the mail template grows or specs start reusing addresses. (S)
Decision log (review-surface layout, 2026-07-21)
- ✅ Full-viewport review surface — the app shells'
<main>is now full-bleed and every non-review page opts back into the centered column viaPageContainer; the review surface (and the diff view's sticky header) use the whole viewport. This fixes a real regression against DESIGN.md §Page layout: all three columns had been packed inside onemax-w-7xlcontainer, squeezing the prose to ~504–576px (worse on larger screens) instead of its 52rem measure. - ✅ Collapsible sidebar + thread rail — both side columns collapse to slim strips (rail strip carries the open-thread count and an orphan dot); preference persists per device in
localStorage, defaults to expanded, and activating a thread re-opens a collapsed rail. Collapsing never widens prose past 52rem. Codified in DESIGN.md §Page layout. - ✅ Highlight restyling is in-place (
restyleAnchorHighlights) — hover/active changes toggle classes on the existing marks instead of rebuilding them. Rebuilding raced the click gesture: the mark receiving mousedown was disconnected before mouseup, so Chrome never firedclickon document highlights (found by the layout e2e probe; unit-tested inanchor-highlight-dom.test.ts).
Decision log (workspace UX wedge, 2026-07-21)
- ✅ M3.5 + M3.6 wedged before M4 (user decision): dogfooding surfaced four blockers — imported docs invisible (home was just an import box), no aggregate progress across parallel imports, no organization, one-by-one import. M4's agent flows also demo far better against an organized, repo-full workspace. SPEC §21 amended; ROADMAP modules added.
- ✅ M3.5 Documents list ships first, as its own module — pulls the "Your docs" half of SPEC §11 forward from M5; it has no notification-data dependency (that's only true of "Needs your attention", which stays M5). Live import-status chips on the list + the existing retry endpoint cover multi-import progress with no new pipeline work.
- ✅ Projects pulled forward from post-v1 — with dedicated pages (user chose pages over slim assign-and-filter): description, project-scoped doc list, attached repo sources. Semantics exactly as pinned in CONTEXT.md 2026-07-15: a free container, never a repo mirror; docs move freely; Unfiled bucket for the rest. Project-level sharing/permissions and References stay post-v1.
- ✅ Repo sources are pull-based with manual Re-scan only — repo URL + ref + path glob → preview matched files → bulk import; Re-scan imports new files, re-syncs changed ones, flags deletions (never auto-deletes a doc). No scheduler/webhook infra now — holds the "v1 is pull-based" charting decision; the M6 GitHub App webhook later drives the same repo-source record. Naming flag for /domain-modeling before M3.6 speccing: CONTEXT.md "Source" is per-document; the repo-level record needs a non-colliding term (working name: repo source).
Open TODOs (from M3.5 eng review, 2026-07-21)
-
Out-of-band import liveness— RESOLVED (M3.6 #93, 2026-07-21): the tracked-repo scan report drives row materialization on the project page (poll the record, merge reported docs into the island, settle via the shared per-row path) — the exact shape sketched here.
Known debt (M3.5 branch review, 2026-07-21)
- Structured sync-error code — dead-PAT detection is
/reconnect/ioversync_errorprose (single origin: TokenRevokedException's copy); the shared retry affordance now drives two surfaces off that regex, so a reword silently flips both. Emit a machine-readablesync_error_codein the document resources and keyimportNeedsReconnectoff it, regex as fallback. (S) - Poll-loop consolidation — RowPoller / DocumentPoller / SharedDocumentPoller each own the setTimeout/settle/cleanup skeleton and the 1.5s cadence is written in three places. Extract one
usePollUntilSettledhook + a single interval constant. (S) - Lean settle read — a list row's settle tick downloads content + plain_text + approvals it immediately discards (only 6 scalars survive into the row). Fine while concurrent imports are few; give the row poller a lean read when M3.6 bulk scans raise the fan-out. (S)
- Pre-existing
/me500 for workspace-less sessions — CurrentUserResource null-derefspersonalWorkspace(); today it self-shields (500 → session null → anonymous branch) but it's an ErrorException in the logs and blocks any future workspace-less surface. Outside the M3.5 diff. (S) - Duplicate-import affordance —
store()has no source_url dedupe and the stay-home form re-enables immediately; a deliberate re-paste mints a twin document. Product decision (dedupe-and-warn vs allow) deferred to M3.6 tracked repos, whose re-scan needs dedupe machinery anyway. (S) - Minor shape sweep — paginator meta typed per resource (thread-types vs document-types); BFF relay envelope duplicated per route; EmptyState hand-rolls the panel StatePanel owns; per_page clamp inlined in DocumentController vs shared with CommentThreadService; settle-announcement round-trips through WorkspaceHome; ImportForm's onImported optional with an unreachable fallback branch. Sweep opportunistically alongside neighboring work. (S)
Decision log (M3.6 naming + seams, 2026-07-21)
- ✅ Term pinned: Tracked Repo (user decision, resolving the /domain-modeling flag from the wedge log): the repo-level record is a tracked repo; per-document Source stays file-granular; "tracked" means remembered-and-re-scannable, pull-based until M6's webhook. CONTEXT.md entry added; SPEC §5.1/§16/§17/§21 renamed from the
repo sourceworking name. - ✅ M3.6 testing seams agreed: API PHPUnit (scan matrix with faked GitHub responses), web vitest (scan-report projection + page states), journey pack with ONE new test-only knob — env-configurable GitHub API host so serve-fixtures emulates minimal tree/contents endpoints (FETCH_ALLOW_HOSTS precedent). Spec: docs/specs/m3.6-projects-tracked-repos.md.
Known debt (M3.6 branch, 2026-07-21)
- Failed-import-then-changed path reads 'unchanged' forever — a doc whose first import failed (no current version) is skipped by the re-scan change detector and reported unchanged even when upstream changed; recovery is the doc-level Retry only. Make the report honest (
import_failedoutcome) or re-attempt the import on re-scan. Surfaced by the #94 agent's self-review. (S) -
Optimistic 'running' can settle on a stale report under a backed-up async queue— RESOLVED (M3.6 branch review, 2026-07-21): the scan trigger now atomically flips the record topendingbefore dispatch, so the server owns "a scan is queued" and the poller can't settle on a stale terminal report. - E2E diagram specs depend on hosted kroki.io when the render cache is cold — kroki.io's mermaid backend outage mid-M3.6 forced a local container to green the pack; CI runs its own kroki service, but local cold runs flake. Consider a kroki fixture/stub for the pack or a documented
KROKI_URLlocal-container step. (S)
Known debt (M3.6 branch review, 2026-07-21)
- GitHub response policy lives twice —
GithubRepoClient(tree listing) re-implements the blob connector's rate-limit classification, header block, per-segment encoding, and untrusted-message sanitizer; its docblock promises parity that only copies enforce. Extract a sharedServices/Githubcollaborator/trait for both. (M) -
— RESOLVED (#98, 2026-07-23): the hook now holdsusePollUntilSettleddeps are caller-ownedpoll/onSettledin refs refreshed every render and reads them at tick time, and takes a singlekey(the poll target) as its only dependency. The stateful loop is extracted asstartPollLoop(injected scheduler) so the no-stale-closure and restart-on-key-change guarantees are unit-tested without a DOM. All four consumers migrated tokey:; the caller-owned effect-deps arrays and their eslint-disables are gone. - Scan status remains triple-encoded —
last_scan_statuscolumn,scan_errorcolumn, andstatus/errorembedded inlast_scan_report; web reads a mix. The pending-flip fix removed the dangerous seam; consolidating to one authoritative representation with derivations at the boundary is still worth doing. (S)
Decision log (design refresh — Open Harbor, 2026-07-23)
- ✅ "Open Harbor" locked as the design baseline (second
/design-exploreround: Chart Room · Signal Deck · Open Harbor; Open Harbor won, with Signal Deck's workspace console merged in). Two DESIGN.md rule changes recorded there: light-first (warmstone-50register; dark fully supported, mapping to the original zinc register) and Space Grotesk as the display font (OFL; self-hosted woff2 at implementation — body/UI/prose stay on system stacks). Losing variants deleted per the 2026-07-03 precedent. - ✅ App surface set mocked and locked (
docs/designs/app-*.html, cross-linked; inventory in DESIGN.md §Canonical mockups): dashboard, project page, document review, version history + diff w/ comment overlay, workspace settings, and a post-v1 members & teams preview (app-teams.html). Ghost-card pattern marks not-yet-built scope inline (M4 agent tokens, M5 queue, M6 connectors, Later teams). - ✅ Tenancy growth shape noted in SPEC §10.1 (no v1 scope change): account (billing/SSO) → workspace (tenancy root) → teams as people-groups for mentions/routing/required approvals — never content containers; magic-link guests stay outside membership.
-
P2 (S) Restyle implementation pass— RESOLVED (M3.7 #99/#100, 2026-07-23): shipped as the Open Harbor core + chrome sweep (when scheduled): self-host Space Grotesk woff2 inweb/(no runtime Google Fonts fetch), flip default theme to light, re-map the--color-fd-*overrides to the amended tokens (stone-50page,-700light chip hues, solid-emerald CTA variant).
Decision log (M3.7–M3.9 eng review, 2026-07-23)
- ✅ One L module split into three (M3.7 restyle+dashboard+General → M3.8 activity+landing → M3.9 i18n) — same total scope; each ends demoable with a small merge (the 74-commit M3.6 branch was the cautionary tale). ROADMAP/SPEC §21 amended; specs rewritten with review decisions inlined.
- ✅ Key review decisions: re-anchor audit events aggregate per re-sync (1A); display context snapshots into audit meta at write time (2A); dashboard degrades quietly on summary/activity outage — the list is never blocked (A1); en-US deep-merge i18n fallback (B1); activity responses are an allowlisted projection — ip/raw meta never serialized (3A); strict locale-cookie allowlist (4A); live rows keep their place under filters, import flips the chip to All (5A); summary refreshes on settle events (6A); one shared query definition for counts + filters (7A);
usePollUntilSettleddeps debt fixed as the first dashboard ticket (8A); all 11 review-surfaced gap tests specced (9A);(workspace_id, created_at)audit index (10A); workspace rename audited (11A); mn-MN display font = system stack — Space Grotesk has no Cyrillic (12A); chip strings as a constrained, length-budgeted glossary (13A). - USER ACTION P2 (S): line up native review of the es-US and de-DE catalogs before Launch (machine-seeded, shipped with M3.9 on 2026-07-25 — 19 namespaces per locale under
web/messages/; mn-MN founder review also pending; the parity test guarantees keys, not quality). The M3.9 gate has lifted — this is now live; check before the Launch announcement.
Known debt (M3.7 branch gate, 2026-07-23)
- Dashboard live-list: a retry/settle/refile local mutation does not invalidate an in-flight lifecycle-filter refetch (
web/lib/use-live-document-list.ts). The retry sub-case can strand a row at "failed" (poller unmounts; re-retry 409s) until reload. Narrow sub-second interleaving, no data loss. Fix via a mutation generation that supersedes/re-issues pending filter replacements. (S) - Dashboard rail: project creation (
handleCreatedappend) racesrefreshProjects(wholesale replace) inworkspace-home.tsx— a stale in-flight refresh can transiently drop a just-created project from the rail/selectors; self-heals on the next refresh. Fix: invalidate the projects gate on create + merge projects by id. (S) - Workspace rename reads the audit
fromsnapshot without a row lock — concurrent same-workspace renames could record a stalefrom. Degenerate under v1 invisible tenancy + best-effort audit (recordSafely); revisit when team workspaces add Member seats. (S)
Decision log (M3.8 activity feed #111, 2026-07-23)
- ✅ Feed type allowlist = 9 events (
ActivityFeedEvent::feedEvents):comment.created,suggestion.accepted/declined,approval.given/gone_stale,reanchor.completed,document.imported/import_failed,workspace.renamed.thread.createdis deliberately excluded — a thread open also emitscomment.createdfor its first comment, so exposing both would double every new-thread action;comment.createdalone covers the user story's "replies".suggestion.declinedis included (the user story says "accepted", but #108 gave declined the same 2A snapshot and an author catching up wants both triage outcomes). - ✅ "re-scans/re-sync digests" surface as
reanchor.completed(the one-line per-flip digest, 1A). A repo re-scan (tracked_repo.scanned) is not allowlisted: it carries no 2A display snapshot (its meta isref/matched/counts— no repo/project display name), so rendering its sentence from the row alone would need either forbidden read-time morph hydration or a write-side snapshot change outside this read-side ticket. A re-scan still reaches the feed indirectly, via the per-documentreanchor.completeddigests it triggers.- Follow-up (write-side) P2 (S): to add a repo-level "Re-scan of {repo}" row (the mockup's project-linked line), instrument
tracked_repo.scannedwith a 2A project/repo display snapshot inTrackedRepoScanService::recordAudit, then allowlist it with aprojecttarget. Belongs to the tracked-repo module, not #111.
- Follow-up (write-side) P2 (S): to add a repo-level "Re-scan of {repo}" row (the mockup's project-linked line), instrument
- ✅
workspace.renamednow snapshotsactor_name(2A) at the write site so the feed renders "{name} renamed the workspace…" from the row alone — a small additive extension to the M3.7 #101 rename meta, flagged by the branch review. - ✅ No client BFF route for the feed: seeded server-side once on page load with no polling/refresh (M5 owns liveness), so — unlike the summary/projects reads — it needs no same-origin BFF refresh route. A failed server seed degrades the panel to nothing behind the existing banner (A1).
- ✅ Target links are workspace-scoped:
ActivityTargetResolverscopes its document-existence sweep to the caller's workspace, so a subject in another workspace drops the link (never emits a foreign id) — "an id is never an access path" for the link target too.
Known debt (M3.8 branch gate, 2026-07-24)
- Audit
record()still throws into three non-feed domain actions — import-retry (DocumentController::retry, where a thrown audit write also skipsImportDocumentJob::dispatch, stranding the document atimporting),ApprovalService::revoke, andDocumentLifecycleService. Pre-existing (unchanged frommain; M3.8 only enum-typed the action) and outside M3.8's feed write-side scope. Move each onto therecordSafelyboundary — and for import-retry, dispatch the job before the audit write. (S) - Activity projection exposes the raw, globally-sequential
audit_logs.id(ActivityEventResource), which decision 3A's field list omits; a tenant can infer system-wide audit volume from id gaps between their own rows. Low severity (no user data/PII). The web uses it only as a React list key — replace with a per-response opaque/relative key or drop it. (S) tracked_repo.scannedcarries no 2A display snapshot so repo scans can't be feed-allowlisted directly (re-scans surface via the per-document re-sync digests they trigger). Write-side snapshot addition if scans should get their own feed line. (S — noted at #111)
Decision log (Update pasted content #113, 2026-07-24)
- ✅ Manual content update rides the existing re-sync pipeline, unforked:
POST /documents/{id}/contentoverwritesdocuments.source_metawith the new paste and dispatches the sameResyncDocumentJob→ResyncService. The sharedUploadConnectorreads the body back, so normalization + warnings, content-hash dedupe (§5.2), the re-anchor ladder, approval staleness, and the onereanchor.completeddigest (M3.8 1A) all come free — no parallel path. The spec-reserved manual-only versioning path (§5.1) finally has a trigger. - ✅ Dedicated endpoint, not an overloaded
/resyncbody: uploads get an Update content affordance, URL docs keep Re-sync untouched (a URL docPOST /content→ 409). A content update needs a size-capped body/resynchas none of, and different feature-gating — overloadingresyncby source type would fork one action's behavior. The job and service stay shared; only the trigger differs. - ✅
source_metaoverwritten so a retry re-imports the LATEST paste: the new body (+ optional replacement title) replacessource_metabefore dispatch, mirroring the initial paste import — a transient-failure retry then re-imports the new content, not the stale one. A failed update never disturbs the current version regardless (SPEC §5.3): the pipeline flipscurrent_version_idonly after the target anchors are durable. - ✅ Authz reuses the
resyncpolicy (memberOf) — the sibling URL trigger's exact gate. A magic-link reviewer holds no workspace membership and is refused 403; under M1 invisible tenancy member == author, so "author-only" holds. Kept identical to re-sync rather than inventing a divergent author-only ability. - ✅ Not behind
resync.enabled: that flag bounds outbound fetch/queue load; a content update spawns no outbound fetch and is an upload's only versioning path, so gating it there would strand pasted docs. Locked with a test. - ✅ Paste size cap shared via
CapsPastedContenttrait: one config-driven byte rule for both the initial paste (StoreDocumentRequest) and the update (UpdateDocumentContentRequest) — the update can never accept a larger body than the original paste. - ✅ Client dedupe honesty: the update surface polls exactly like re-sync; version-label unchanged +
last_sync_status: ok⇒ an honest "no new version — content unchanged" message rather than a phantom success. - ✅ Independent-review hardening (codex): (1) author-only — a dedicated
updateContentpolicy =authorOf(not the membership-gatedresync), exposed as theupdate_contentcapability the web gates the affordance on, with a same-workspace non-author 403 test; content replacement is a stronger capability than review, matchingupdateLifecycle. (2) Title preserved, not re-set — the surface versions the body only; the controller keeps the author'ssource_meta.title, removing the input entirely so a same-content/new-title submit can't dedupe-drop the rename. (3) Prior failed status cleared before dispatch (likeretry()), so the completion poll never reads a stale failure as this attempt's outcome.
Known debt (Update pasted content #113 branch, 2026-07-24)
- Overlapping content updates coalesce under
ShouldBeUnique(ResyncDocumentJobunique per document): a second update dispatched while the first job is mid-flight is suppressed, so if the first job already read the oldsource_meta, the second paste is lost (and the audit actor is the first requester's). Inherited from the shared re-sync job's coalescing (harmless for URL re-sync, which re-fetches; an upload's payload is client-supplied so a lost update is user-visible). Narrow: the dialog disables while pending + polls to completion, so a single UI user can't overlap; it needs concurrent API calls, and never happens under the sync queue. Fix if it bites: an operation generation on the document that re-queues when it changes. (S) - A slow async update can be mis-reported as a dedupe: the shared completion poll (
waitForResyncCompletion, 18s budget) can't tell "identical content deduped" from "job still running", so a job slower than the window surfaces "no new version". Low risk for uploads (no outbound fetch — projection+re-anchor only, well under 18s) and impossible under the sync queue; the honest signal would need a server-side operation state the shared pipeline doesn't carry. (S) - Re-anchor fuzzy fallback crashes on an anchor selection longer than 32 chars (
web/lib/reanchor-core.ts→@sanity/diff-match-patchmatch, which throws "Pattern too long" forpattern.length > 32). When a re-sync/content-update needs the fuzzy path for a thread (i.e. its exact text no longer appears — the relocated/orphaned case) and that thread's anchored selection exceeds 32 characters,POST /internal/reanchor500s and the whole re-sync fails. Pre-existing (the shared pipeline — URL re-sync hits it identically; #113 only surfaced it via a journey with a long orphaned anchor) and it fails safe — the current version is never disturbed (SPEC §5.3), it just can't advance. Fix: in the fuzzy path treat a>32pattern (or a too-long window) as no-match → orphaned, or chunk the pattern, instead of callingdmpMatchunguarded. Add a reanchor-core golden case for a long orphaned anchor. (S)
Decision log (landing conversion pass, 2026-07-24)
- ✅ Funnel goal fixed: run the demo, then leave an email — the landing's two desired actions, in that order. New affordances: a one-click sample demo ("Render Kedge's own SPEC.md" — dogfooding as the proof) in the hero and the closing band; a header Get started → /signup; and a closing email CTA whose address rides sessionStorage into the signup form (never a query param — no PII in URLs or server logs).
- ✅ Positioning of record: many sources in → one readable review page, discussion anchored to the text, AI to strengthen the doc. The pain is named in the hero ("reviews die in PR threads and meeting chat"); the former footer-only tagline ("Comments that keep their place") is promoted to the proof-shot heading.
- ✅ Real screenshot over mockups: the review surface is shown as an actual product screenshot (
web/public/landing/review-surface.webp) in a browser-chrome frame. Product UI is never AI-generated; the decorative landing art (hero chart, roadmap chart, boathouse, OG card) is generated illustration placed asaria-hiddendecoration only. OG card ships asweb/app/opengraph-image.jpg(Next file convention). - ✅ Roadmap compressed to outcomes ("Live today / Charted next") — week-level milestone IDs read as risk to evaluating buyers; the build-in-public flavor is kept to one intro line, and the "kedge.review goes live" card (confusing when read on kedge.review) is gone.
- ✅ House copy style: no em dashes, no emojis, icons are lucide — locked with a unit test asserting the rendered landing markup contains no em dash.
Decision log (source provenance, 2026-07-24)
- ✅ New wedge module M3.10 — Source provenance (specs/m3.10-source-provenance.md); SPEC §11 + §21 amended. Dogfooding pain: the docs list is provenance-blind — a tracked-repo RFC and a pasted note render identically, and a repo's own hierarchy is erased at import despite being stored (
tracked_pathis the scan diff key,source_type/source_urlon every row). Fix: read-only provenance chips on every row + project pages grouping repo-sourced docs under their tracked repo in path order with directory dividers. Display-only — no migration. - ✅ Boundary held: this is not folders. CONTEXT.md's Project (container) / Source (origin) split and the §2 wikis/folders non-goal are unchanged — repo structure surfaces as derived, read-only metadata inside projects, never as user-managed hierarchy. Provenance derivation is server-side in one shared place (
AbstractGithubBlobConnectorURL parse extracted, not duplicated) per the §5.4 anti-drift rationale. - ✅ Ordering vs i18n: M3.10 runs before M3.9 (i18n) and ROADMAP now records the dependency edge — i18n's glossary wants stable strings and this module adds chip/section strings.
- ✅ Deferred: a home-list "group by: project / source" toggle — revisit only if the chips + project-page sections prove insufficient after living with them.
Known debt (M3.10 branch gate, 2026-07-24)
- Live-fill prepend transiently breaks path order in repo sections — freshly imported / re-scanned rows prepend at the top of a path-ordered section (M3.6 live-fill contract: new rows surface visibly, settle in place) until the next server-ordered read, so a directory-divider label can transiently repeat. Accepted at the #118/#119 codex gates: the spec tolerates repeated labels, keys stay unique, and it self-heals on reload. Revisit only if an ordering-aware merge can keep the just-imported row visible (not buried off-screen in its path slot). (S)
order=pathtiebreak is not fully index-covered —orderBy('tracked_path')->orderByDesc('id')leans on the standalonetracked_pathindex for the sort; theid DESCterm builds a temp b-tree. Spec-decided (no migration in M3.10); bounded by the per-repo file cap, and(tracked_repo_id, tracked_path)uniqueness makes the tiebreak defensive-only for pagination stability. Fold into a future index pass only if repo sections grow past the cap. (S)
Known debt (M3.9 branch gate, 2026-07-25)
- Client-lib fallback error prose is still English (
web/lib/*-client.ts,content-update.ts,resync-polling.ts) — these fallbacks serve every surface, so localizing them is the module-wide error-code→catalog mapping pass SPEC m3.9 already sketches forsync_error_code; do it once the API's machine-readable codes land, not string-by-string. Flagged by the #126 codex gate, deferred with rationale on the ticket. (M) anchor-highlight-dom.tsaria-labels ("Open thread"/"Open threads") are English — raw-DOM builder outside the component tree, ande2e/i18n-shared.spec.tsasserts the English label after a locale switch, so this is a coordinated two-file fix (inject translated labels + update the journey assertion). (S)documents.diff/diff.jsonduplicate two keys (current,backToDocument) across the #123/#126 lane boundary — consolidate intodiff.jsonand drop thedocuments.jsoncopies in all four locales. (S)- Pre-existing:
next buildfails prerendering/_global-error(useContextnull) — verified identical onmainwith the entire i18n diff stashed, so NOT introduced by M3.9; web CI gates are types+vitest+e2e (dev server) and stay green. Deserves its own ticket before any deploy that runsnext build. (S) /docsrenders dynamically now — the root layout reads the request to resolve locale (inherent to cookie-based no-routing i18n), which drops static rendering for the dogfood shell even though its content stays English. If static/docsmatters, split root layouts by route group at Launch tail. (S)
Decision log (observability, 2026-07-28)
- ✅ Laravel Nightwatch adopted as (optional) API observability, token-gated off — after the 2026-07-27 preview outage went unnoticed for ~24h. Distribution stance mirrors the Nova hard rule:
laravel/nightwatchships incomposer.json, butconfig/nightwatch.phpgatesenabledonNIGHTWATCH_TOKENbeing set, so out of the box the open-source app has zero behavior change, zero outbound traffic, and no agent process;NIGHTWATCH_ENABLED=falsealways wins. Preview wiring (preview-grade shortcut, revisit at M7): the api entrypoint backgroundsnightwatch:agent --listen-on=0.0.0.0:2407inside the api container when a token is present; worker/scheduler ship to it viaNIGHTWATCH_INGEST_URI=api:2407. M7's reference compose should use the officiallaravelphp/nightwatch-agentsidecar image instead — and must NOT include it as a required service. SPEC "Pluggable services" + §21 standing constraint amended. - USER ACTION P2 (S): create the Kedge app/environment at nightwatch.laravel.com, set
NIGHTWATCH_TOKENin the Coolify env for the preview, redeploy. — Done; verified in Coolify on 2026-08-22. - ✅ Preview requests and commands sample at 10%; exceptions and scheduled tasks stay at 100% (2026-08-22) — the two high-volume execution contexts had exhausted the organization event quota at their 100% defaults. Both rates remain operator-overridable through the corresponding
NIGHTWATCH_*_SAMPLE_RATEenv vars. - ✅ The preview queue worker no longer recycles hourly (2026-08-22) —
queue:work --max-time=3600exited cleanly every hour, Docker'sunless-stoppedpolicy restarted it, and Coolify classified the intentional restarts as crashes. At 24 restarts against a limit of 10, Coolify automatically stopped every Kedge container. The worker now stays long-lived and is replaced on deploy; do not reintroduce container-level periodic recycling while Coolify's application restart budget is enabled. - Nightwatch won't page on a full-stack outage — it observes from inside the app, so a stopped stack just goes silent (exactly the 07-27 failure). An external uptime check on
kedge.preview.batjaa.site/upremains unowned. (S)
Decision log (persistent sessions, 2026-08-10)
- ✅ Remember-me on every sign-in path — password login, registration, GitHub OAuth, and reviewer magic-link completion all call the web guard with
remember: true. Rationale: the Sanctum SPA cookie flow stored auth only in the 120-minute server session, forcing a fresh sign-in every day; a review tool must not do that. The long-lived recaller cookie (Laravelforever, ~400 days, same domain/secure/SameSite attributes as the session cookie) silently re-establishes a fresh session — with session-ID regeneration — after idle expiry, soSESSION_LIFETIMEstays short at 120. Sign-out still cycles the remember token and clears the cookie. No opt-in checkbox: staying signed in is the product default, matching contemporary SaaS.
Decision log (violet AI CTAs #143, 2026-08-19)
- ✅ The violet agent register extends to AI CTAs in both themes — violet means a model runs; a human write (post, reply, split approve, document approve) keeps zinc-900/emerald, and Copy stays zinc (it runs nothing). Tones centralised in
web/components/app/ai-tone.ts; DESIGN.md amended in the same commit (status hues may now sit on a control, not just a mark — with the quiet/icon variants, the colour-only-geometry rule, and a hover exception pinned). Split Approve moved the other way — it wore the agent hue in dark mode, the same collision in reverse.
Known debt (#143 branch gate, 2026-08-19)
- A disabled AI CTA in light mode is fainter than the old zinc-900 fill at the same opacity — WCAG exempts inactive controls, and dark mode has looked this way since M4 (the themes now agree). Revisit if a disabled-state pass ever happens. (XS)
Decision log (sticky sidebar #146, 2026-08-19)
- ✅ The review-surface pin offset is measured, not guessed — the surface observes the document header (ResizeObserver + resize, rAF-coalesced) and publishes its bottom edge as
--kedge-pin-top; the sidebar and both column toggles pin there. The header's own sticky offset is read from its computedtop, so nothing restates the constant. Fixing the inert sticky (auto-height<aside>gave it zero slack) exposed thattop-32was a guess at a header that measures 107–164px depending on approvals and width.
Known debt (#146 branch gate, 2026-08-19)
document-diff-view.tsx:137carries the samexl:top-32under-header defect — its DiffHeader also renders an ApprovalRoster; lift the measured-offset mechanism into a shared hook. (S)SCROLL_SPY_OFFSET = 136is a constant while real clearance is dynamic — the TOC highlight can lead the visible heading under a tall header; pre-existing. (S)- Tailwind v4 scans comments and docs content — any class-shaped string (an arbitrary-value utility spelled with square brackets, even in prose or a code comment, even in
web/content/docs/) is emitted into the build; an invalid one breaks global.css and 500s the server until.next/.sourceare cleared. This very entry originally triggered it. Keep bracketed utility literals out of prose. (XS)
Decision log (ask-about-the-doc #139, 2026-08-19)
- ✅ Dedupe exemption is a property of the run type, not a branch —
AiRunType::isDedupeExempt()sits besideisPerActor(), andAiRunLedger::joinable()returns before the document row lock for an exempt type. A sixth type's author has to answer the question deliberately. - ✅ Ask runs are per-actor (a strengthening over the ticket, which is silent) — the run carries one person's question and run ids are sequential, so
AiRunPolicy::viewscopes it to the asker, exactly as for reply drafts. - ✅
ai_runs.requestis a new column, not a corner ofinput—inputis frozen at first assembly; folding the question in would drop every ask's scope metadata. - ✅ No GET latest-ask, deliberately — every other artifact has a re-attach read; an ask must not, or the ledger becomes somewhere to go and read one. Ephemerality is structural in the web too: the panel's state unmounts on close.
- ✅ "Ephemeral" is a claim about the surface, not storage — the run lands in the cost ledger like every other; the copy promises only that nothing reaches the review and no other screen shows the answer.
Known debt (#139 branch gate, 2026-08-19)
- ✅ No e2e journey for the ask surface — paid down 2026-08-25 by #151.
web/e2e/ai-ask.spec.tsdrives the real thing: open the chat from a selection → answer → follow-up → close/reopen retains the transcript → reload clears it. The follow-up half can actually fail — the scripted fake answers a question carrying prior turns differently from a first one, so a client that stopped sending the transcript breaks the journey instead of passing it. - A long document is answered from its opening — passages read in document order, overflow becomes coverage; proximity-to-selection ordering would serve passage-scoped asks better if coverage can stay truthful. (S)
- Quote/body version skew — a re-sync between POST and job means the quote is from the page's version while the body reads at job time; nothing wrong persists (asks write nothing) and
input.document_version_idrecords what was read. Same property as the digest. (S) StoreThreadRequest.anchor.heading_pathis unbounded in depth — same class as the prompt-payload bug fixed here, lower stakes (persisted, not prompted); the cap belongs there too. (S)- Repo-wide question: FormRequest validation runs ahead of the controller's Policy on every v1 endpoint using one — a non-member's malformed payload 422s before the 403. Consistent today; decide once, not per endpoint. (S)
Decision log (ask chat panel #151, 2026-08-25)
-
✅ Client-held transcript, not a server-side conversation model — the decision this ticket turns on. A
conversations/conversation_turnspair (or aparent_run_idonai_runs) is the obvious shape and was rejected: it would mean a READ path returning someone's questions and answers, which is precisely what #139 refused to build, and per-actor privacy would go from "structurally impossible to leak — there is nothing stored" to "one more Policy nobody may ever get wrong". Client-held keeps both #139 properties intact at zero new tables: the ledger still never becomes somewhere to go and read an ask, and the server holds no conversation to scope. Cost, accepted: no resume across reload, tab, or device; the transcript is re-uploaded on every follow-up (bounded, and small beside the document it rides with); and a long conversation buys its context out of the document's share of the budget. Resume would require exactly the server model this decision declines, so it is not deferred work — it is the thing traded away. -
✅ A replayed answer is history, never evidence — the multi-turn injection question, answered in the prompt itself. Anyone who can POST can claim any "previous answer", and a poisoned document can produce a real one; either way it re-enters the next prompt looking like something the assistant said. So each turn is fenced FIELD BY FIELD (a turn boundary the content cannot forge textually) and the task block tells the model in as many words that prior answers are context for what the reader means and never a source of facts about the document.
-
✅ The transcript is budgeted inside
ContextBudget, as context — it rides where the quote rides, so the assembler subtracts it before packing passages: a long conversation shrinks the document and the coverage sentence says so. As sections it would have competed with the document and could have been dropped as "uncovered" — silent truncation, which SPEC §14 forbids. -
✅ The transcript's ceiling is budget-relative (16k chars / 8 turns, but never more than a quarter of
context_tokens) — found by the test that pins it. A fixed cap is a quarter of the DEFAULT budget; against a retuned smaller one the same transcript ate the whole ceiling, every passage was skipped, and the ask answered "this document has no readable text". Shrinking the document is the design; starving it is a bug. -
✅ The rail gets an explicit OCCUPANT (thread rail ⟷ ask chat) rather than a third column or an overlay — a third column pushes the prose below its 52rem measure and an overlay buries the rail. The chat wins while open, including over a collapsed rail; the collapse preference is read on close rather than overwritten, so the slim gutter stays a thread-rail control.
-
✅ The mobile sheet is mounted exclusively, not CSS-hidden — it locks body scroll and claims the document's Escape key, so an
xl:hiddencopy would do both to a desktop it is invisible on. The docked rail panel is not modal and keeps Escape scoped to its own subtree. -
✅ A settled run is matched back to its turn by RUN id — retrying a mid-conversation turn replaces its run, so a poll resolving afterwards finds no turn holding the old id and is dropped. Matching by turn id or index would let a superseded answer overwrite the fresh one.
-
✅ The replayed transcript is scrubbed from the run as it lands (codex gate) — it has to reach
ai_runs.requestbecause only the run id rides the queue, but leaving it there meant every follow-up wrote an append-only copy of every earlier question and answer: an eight-turn conversation duplicated across eight rows and every backup, which is a retention profile nobody agreed to and which contradicted the spec's own claim. Scrubbed atlandTerminal(after the queue can no longer re-read it); the run's own question stays, andinput.transcript_turnskeeps it diagnosable without keeping what was said. -
✅ The transcript is fitted against the document, not just capped (codex gate) — the character cap bounds the transcript in isolation, but a chunk's fixed cost is the transcript PLUS a maximal question, quote, heading path, title, fences and task. Together, at a retuned 2,000-token budget, they floored the section capacity and the ask answered "this document has no readable text yet".
PromptAssembler::sectionCapacity()now exposes the one budget formula, and the builder drops oldest turns until at least one passage still fits beside them. -
✅ The client is stricter than the builder about the cap — where the builder cuts a single irreducible turn to keep something, the client drops it and sends nothing. The client's job is to never earn a 422 mid-conversation; the builder's is to never hand the provider more than it agreed to, whatever the row says.
-
✅ Only the last turn is retryable (codex gate) — a failed turn contributes nothing to the transcript, so a reader can ask a further question while it sits there failed; retrying it afterwards would slot its answer in FRONT of an answer written without it, and every later request would replay the pair in display order as though that ordering were causal. Re-asking is the honest way back to a turn the conversation has moved past. The rule is one pure predicate (
askTurnIsRetryable) shared by the hook and the panel so they cannot disagree. -
✅ "New conversation" carries an epoch (codex gate) — resetting mid-POST used to leave
sendingReflatched, so the next question was accepted by the composer, silently refused by the hook, and its draft cleared anyway. The epoch drops the in-flight result, the reset clears the guard, andask()now reports whether it took the question so the composer only clears a draft that was actually asked. -
✅ A run past the client ceiling is retryable (codex gate) — a worker killed hard enough never runs its terminal handler, so nothing will ever settle that poll. The taking-too-long state promised "Retry" while offering no button and holding the composer disabled; it now offers the real one.
-
✅ The gutter always hands the column back to the thread rail (codex gate) — it is the thread rail's control, so using it means "give me the rail, in whatever state my preference says". Coupling only the collapse direction still left "Show rail" as a visible no-op whenever the chat was the occupant.
-
✅ The conversation is dropped when the version changes (codex gate) — a re-sync re-renders the surface with new props rather than remounting it, which left a v1 quote and v1 answers travelling as context for a question answered from v2, under a panel still open over a document nobody was reading any more. The same effect closes the panel if the AI capability goes away, so an open chat cannot outlive the gate meant to hide every AI affordance.
-
✅ One sheet at a time below xl (codex gate) — the thread sheet and the ask sheet are both modal; opening either now closes the other, rather than stacking two focus traps, two Escape handlers and two body-scroll locks.
-
✅ Focus is captured during render, not in an effect (codex gate) — child effects run before parent ones, so the composer's autofocus was recorded as "what had focus before" and closing tried to return focus to a node it had just unmounted. Restoration is also skipped when the opener is gone (the selection popover unmounts as the panel opens), leaving focus where the browser put it rather than dropping it to
<body>.
Decision log (parallel e2e flake — reviewer mailbox, 2026-08-25)
Found while diagnosing CI failures on the #151 PR. The failing journeys were not caused by that branch: base 3cf9fdf reproduces them, and the root cause predates it.
- ✅
latestReviewerMagicLinkUrlcould return ANOTHER reviewer's link — the real bug, dormant since the helper was written and exposed by #148 putting the pack on 4 workers. It took a ±character window around the last occurrence of the reviewer's address in the shared Laravel log and returned the LAST magic link inside it. Four journeys mail reviewer links into that one log (i18n-shared,i18n-review,reviewer-magic-link,suggestion) and one MIME message runs to a couple of hundred lines, so in parallel the forward half of the window reached into the next reviewer's message. Verification then ran for the wrong reviewer and the journey timed out waiting for a redirect carrying?verified=1. Serial runs were immune: nothing newer than your own message existed yet when you read. Fixed by matching per LOG ENTRY on theTo:header — the log mailer writes one message per entry — withtest/mailbox.test.tspinning the interleaving. The old logic demonstrably returns the other reviewer's token on that fixture. - ✅
selectDocumentTextwaits for its ownmouseuphandler — it works by DISPATCHING a mouseup, which before hydration lands on nothing: no selection is captured, no affordance appears, and the journey fails with "Comment button not visible" on a page that plainly shows the document. It now waits for the ancestor React stampedonMouseUponto — the exact dependency, not a hydration heuristic — and stays non-fatal forformIsHydrated's reason. - Evidence: under 4-vCPU emulation at CI's 4 workers the pack is green (3/3 runs). Under deliberate oversubscription (10 workers)
i18n-shared:78failed 2/8 on the #151 branch AND 2/6 on unmodified base (base also flakedai-split), then 0/8 with the mailbox fix. The #151 branch adds ~20 KB of client JS (0.9%) and one journey; neither is implicated. - Open: at 10 workers — 2.5× CI's concurrency —
auth-edges:58flaked once. Not chased; that load is well beyond what CI applies. If the pack is ever run at more than 4 workers, expect more timing flakes of this family. (S)
Known debt (#151 branch gate, 2026-08-25)
- No streaming — a chat without streaming feels slower than one with it, and polling per turn is what ships. Reverb realtime run updates remain their own future ticket (accepted debt, m4 spec). (M)
- One question in flight at a time — the composer is disabled while a turn is pending, because a second question sent concurrently could not carry the first as context and the answers would land against a transcript that never held them. Parallel asks would need turn-level ordering the panel does not have. (S)
- The transcript re-uploads on every follow-up — bounded at 16k chars and small beside the document, but it is per-request bandwidth a server-side conversation would not spend. Only worth revisiting if the caps grow. (S)
- Swapping the rail occupant unmounts the thread rail, losing transient thread state — an in-progress comment edit or an in-flight reply-draft run is discarded when the chat takes the column. Raised at the #151 codex gate; pre-existing rather than new — collapsing the rail via the slim gutter has always unmounted it the same way, and #151 only adds a second trigger. Fixing it properly means lifting thread-card transient state out of the cards, which is its own refactor. (M)
- Answers are still plain text —
whitespace-pre-wrap, no markdown rendering of model output, unchanged from #139 and deliberately out of scope here. (S)
Decision log (AI provider gate #140, 2026-08-19)
- ✅ The AI gate follows the selected provider's credential —
AI_PROVIDER(defaultanthropic); enabled iff that provider'skeyin the SDK's published table (api/config/ai.php) is a non-blank string. Kedge keeps no second list of provider names and application code names no provider at all (tokenized scan test over app/, routes/, bootstrap/, database/).AI_ENABLEDstill only forces off. - ✅ The credential is the provider's own
key, never an ambient one — Bedrock requiresAWS_BEARER_TOKEN_BEDROCK; anAWS_ACCESS_KEY_IDset for S3 must not unlock model billing (found at the codex gate, with three more fail-open env coercions:OLLAMA_API_KEY=falseviafilled(),AI_PROVIDER=false|true|null|0falling back to the default). - ✅ Pricing is keyed by provider — the same model id resold elsewhere is a different price; unpriced records
cost = null(SPEC §14 amended). - ✅ Keyless providers acknowledge themselves — Ollama takes no key;
OLLAMA_API_KEY=localis the operator's deliberate opt-in, the only way the gate can tell a local setup from an unconfigured one.
Known debt (#140 branch gate, 2026-08-19)
- The kill switch reaches queue workers only on recycle —
queue:workholds boot-time config; queued runs stop within--max-time(1h) or at deploy. Live gating needs shared state read per job — a spec-level decision. Pre-existing property of #130, now documented. (M) - Text capability isn't checked at config time — selecting an embeddings/audio provider shows the surface; runs fail with
provider_unsupportednamingAI_PROVIDER. (S) - Published
config/ai.phpfreezes the provider table — a provider added by a future SDK release needs a re-publish. (S)
Decision log (version switcher #141, 2026-08-19)
- ✅ Version strip capped at the last 3 pills (
VISIBLE_VERSION_LIMIT); collapse starts at N+2 so a click never hides exactly one version. The viewed version and the current version are always pinned onto the strip —aria-current="page"and thelatesttag can never hide in the overflow menu (which shows a(viewing)marker instead, keeping exactly onearia-currenton the page). The menu is a native<details>disclosure (the import-warnings idiom): server-rendered, keyboard-accessible, real?version=links, no client JS, no Fumadocs fork.
Known debt (#141, 2026-08-19)
- Version menu rows date off
synced_at—DocumentVersionResourceexposes nocreated_at;synced_atis set on every create so it is correct today, but semantically it is "last synced". Fold into the OpenAPI/TS-codegen contract item. (S) - Pre-existing: the authenticated app-shell top nav forces a ~461px minimum page width — every authenticated page (
/,/settings, documents) scrolls horizontally below ~460px viewport, independent of the version switcher. Found during #141 verification. (S)
Decision log (M4 journey pack #137 + module close, 2026-08-18)
- ✅ E2E AI seam: the served api scripts every agent at boot via
FakeAiServiceProvider, double-gated onAI_FAKE_RESPONSESANDAPP_ENV=e2e|testing(FakeAiGateTestre-evaluates the real config expression across environments, so the production-safety half is asserted, not assumed). The in-processAgent::fake()has no test process against a served app. Journeys assert AI content (scripted constants + coverage sentences computed from the real review), never affordance presence.web/e2egains its first Node dependency:@modelcontextprotocol/sdk(devDependency) for the real MCP client hop. - ✅ M4 module complete — all nine tickets (#129–#137) shipped on PR #138; demo criterion automated end-to-end in the
mcp-agentjourney.
Known debt (M4 #137 branch gate, 2026-08-18)
- Split panel closes under the author — approving a proposal focuses the new thread and collapses the source card, so "2 of 2 approved" is never observable after approve-all; the journey asserts the rail instead. Consider keeping the panel mounted until dismissed. (S)
- Hydration probe reads React internals —
e2e/helpers.tswaits on__reactProps$/__reactFiber$, degrading to a no-op; a stable readiness signal would be better. (S) - Local diagram journeys depend on hosted kroki.io — intermittently unreachable from a long-lived served PHP process locally (CI uses a Kroki container); a loopback Kroki fixture would make the pack hermetic. (S)
USER ACTIONS (M4 deployment carry-overs, from #128, 2026-08-18)
- USER ACTION Provision
ANTHROPIC_API_KEYin the preview/SaaS env for dogfooding. — Done 2026-08-19: key set as a Coolify platform env,${ANTHROPIC_API_KEY:-}passthrough added todeploy/preview/compose.yml(x-api-env, so api/worker/scheduler all receive it); post-deploy/api/v1/configreportsai.enabled: true. - USER ACTION Post-deploy verification: one real digest on a real doc + one MCP smoke call with a freshly minted token.
- USER ACTION Manual dogfood before module close-out review: digest this module's own review comments with the feature they specced.
- USER ACTION Decide the #134 fragment-vs-anchor question raised on PR #138 (splits fork the whole comment, differentiated by anchor only).
Decision log (M4 MCP AI artifacts #136, 2026-08-18)
- ✅ MCP AI artifacts are reads of the latest completed run — no tool starts a generation; a pending/failed run is reported via
latest_run_statusbut never served.generate/regeneratejoined the human-only verb list the closed-surface test refuses to see in any tool name. - ✅ AI-disabled withholds artifacts rather than erroring — MCP stays up (independent gate), but with no key the artifacts go with the rest of the withdrawn AI surface; MCP is not a back door.
- ✅ Staleness = three signals, not two — version moved, thread count moved, or
review_activity_changed(thread/comment/anchorupdated_atvs the run'screated_at; found at the #136 gate: movement inside an existing thread — a just-accepted edit — was invisible to the first two). Module spec amended in the same commit. - ✅ Staleness lives in a shared service (
App\Services\AI\AiArtifactStaleness→StalenessReport), not in a tool, so the web panels can read the same flag.
Known debt (M4 #136 branch gate, 2026-08-18)
- Staleness is second-resolution — a review change landing in the same wall-clock second the run was requested, after assembly, reads as fresh. Fix: builders freeze a monotonic review watermark (max comment/anchor id) into
ai_runs.inputat assembly and compare ids, not timestamps. (S) - Improve-prompt thread population is mirrored, not shared —
AiArtifactStaleness::threadsNow()re-spellsImprovePromptBuilder's carrying-thread predicate; a builder-agreement test catches drift, but one scope object owned by the builder would be better. (S)
Decision log (M4 comment splits #134, 2026-08-18)
- ✅ The model proposes text; the server computes offsets —
SplitAnchorLocatorfinds the model's verbatim quote in the live projection and derives UTF-16 offsets itself, bounded to the source thread's newest current-version anchor. A repeated passage or quote yields NO anchor rather than a plausible wrong one (fork validation cannot tell identical copies apart); the proposal still forks, inheriting the source anchor like a manual fork. - ✅ Approval is a client-side fork POST — there is deliberately no server-side materialize endpoint; the run's output is inert until the author approves each proposal through the same Policy, validation, and idempotency as a manual fork.
- ✅ The split POST carries the viewed
document_version_idand the server 409s a mismatch — the version gate cannot be a UI race; a stale page is never handed anchors into passages it is not displaying. - ✅
ai_runs.targetis part of the dedupe key (#133's migration carries the columns) — a per-comment run never joins another comment's; null is its own bucket, not a wildcard.
Known debt (M4 #134 branch gate, 2026-08-18)
- An approved split forks the WHOLE comment — the proposal's title and fragment never persist; threads differ by anchor only, per eng review §9 (the fork endpoint gains only an optional anchor). The panel states this plainly, but whether a split should carry its fragment (fork accepting a body — a real spec amendment) is an open product question for the module review. (M)
- Split proposals may only anchor inside the source thread's passage. (S)
AiRunResourcedoesn't exposetarget— the panel doesn't need it; MCP artifact reads may. (S)
Decision log (M4 MCP server #135, 2026-08-18)
- ✅ The MCP tool list is a backed enum (
App\Enums\McpTool) with tests asserting it stays closed — no approve/lifecycle/resolve/suggestion/share tool exists, and the server instructions say so plainly. Suggestion creation is refused with an explicit error, never silently dropped. - ✅
post_commentstarts a thread;replyjoins one — SPEC §15's sketchedpost_comment(thread?/anchor?)would duplicatereplyvia its optionalthread; the table now matches the implemented pair. - ✅
RequireAgentTokenAuthmirrorsRejectAgentTokenAuth—auth:sanctumalso accepts the session cookie, so without the mirror a signed-in human's browser could drive the tools and write agent-badged comments. The MCP endpoint takes agent tokens and nothing else; agent payloads carry nocan_*capability flags. - ✅ MCP refusals are one unified not-found/not-yours answer, closing the id oracle REST keeps for its UI; idempotency keys are namespaced and hashed per token so two credentials can't collide.
- ✅ Three limiters on three different things — per-IP unauthenticated ingress, per-token authenticated calls, tighter per-token writes;
mcp.writeaudit rows only for writes that actually wrote.
Known debt (M4 #135 branch gate, 2026-08-18)
- The two-connection revocation barrier is skipped on the suite's SQLite — only exercised against MySQL/Postgres, where the row locks are real. (M)
mcp.writeis not in the M3.8 activity-feed allowlist — agent writes don't surface in Recent activity; revisit whether they should. (S)- MCP lists paginate page/per_page like REST, not cursors (S) · no per-tool token permissions (SPEC out-of-scope) (S) ·
laravel/mcppinned^0.9— revisit at v1. (S)
Decision log (M4 AI substrate + digest tracer, 2026-08-18)
-
DB_QUEUE_RETRY_AFTER(90s) sits belowAI_JOB_TIMEOUT(300s) — a slow generation on the shareddatabasequeue can be redelivered to a second worker and double-billed (both calls spend; the loser's spend is discarded). Folds into the dedicated-AI-queue item; found at the #133 gate, pre-existing from #130. (M) -
Collapse
AiRunController::digestontoAiRunStarter— the digest start path still inlines the dispatch-or-fail logicAiRunStarter(#133) now owns; unify once #134 lands. Same pass: unify the duplicate phase-machine aliases (aiArtifactPhase#132 /aiRunPhase#133) onto one run-generic name. (S) -
Thread-triage authz rows — reply-draft/summary authorization lives in
AiThreadRunTest, notAuthorizationMatrixTest; add matrix rows for the thread AI endpoints. (S) -
✅ Resolved threads are excluded from the improve-prompt wholesale (#132) — an accepted suggestion sitting on a resolved thread never reaches the artifact. Matches the AC as written ("resolved threads and declined suggestions are excluded"); revisit only if authors report losing approved edits they expected to ship.
-
Extract the shared chunk-prompt loop from the AI generators once #133/#134 land — refusal check, structured-output check, per-call spend,
markSpendUnknownwill exist in three copies by end of M4. (S) -
In-app artifact staleness: a completed digest/improve-prompt re-attached after a re-sync or triage change still renders as current. #136 adds the stale flag for MCP; the web panels should read it too. (S)
-
✅ A client anchor on a fork of a document-level thread is a 422
thread_not_inline, not a silent no-op (#129) — document threads never carry anchors, so persisting one would break the rail invariant and silently dropping it would hide a client bug in the #134 split UI. New error codethread_not_inlineon the fork endpoint; the split client should only ever anchor forks from inline threads. -
✅
GET /documents/{id}/ai/digestadded to the API surface (SPEC §17 amended) — the eng review's "panel re-attaches to the latest run on mount" (§8) needs a read that resolves a run without knowing its id. A singular latest-run read (200 + run, 204 when none) beats a paginated list for a surface that only ever wants the newest. -
✅ Unknown generation faults classify as DETERMINISTIC — the transient/deterministic table names every fault we can recognize; anything else is far likelier to be our bug than a provider blip, and retrying it three times bills the workspace's key three times for the same answer. Cost-safe default, documented in
AiFailureClassifier. -
✅ An unpriced model records
cost = null, never 0 — a missing number is honest; a fabricated one silently corrupts the AI-cost/day metric (SPEC §19). Tokens are still recorded. -
✅ The digest's context is the document title plus its body only when the body fits a 25% share of the budget — otherwise the prompt says the body was left out and the model works from each thread's quoted anchor. Coverage stays thread-based so "covers N of M threads" means one thing.
-
✅ A section too large for a single chunk is excluded, not truncated — silence is the one outcome SPEC §14 forbids, so an oversized thread leaves via the coverage line instead.
-
✅
AI_ENABLEDis a kill switch only (post-review) — it can force the AI surface off and can never force it on; a configuredANTHROPIC_API_KEYis the sole way to enable it, and the provider is a hardcoded v1 pin so the gate can never disagree with the provider it guards. Mirrors the Nightwatch stance ("NIGHTWATCH_ENABLED=falsealways wins"). The queued job re-checks the gate before contacting a provider, so pulling a key stops in-flight spend instead of billing once per queued run. -
✅ Spend is written to the ledger per model call, not at the end (post-review) — a chunked run that dies partway still reports what the completed chunks cost, and a transient retry records the chunks it genuinely re-billed. Prompt scope is written before the first call so a failed run is still diagnosable.
AiGenerationtherefore carries output only. -
✅ In-flight runs are subject to stale takeover (post-review, the M3.6 scan idiom) — an in-flight run older than
ai.stale_after(default 30 min, past the worst legitimate tries x timeout + backoff) is failed asabandonedand replaced, so a hard-killed worker can't leave a row every later request joins forever. A queue that refuses the dispatch fails the run on the spot for the same reason. -
✅
ai.max_threadsbounds what one assembly hydrates (post-review) — the context budget guards the model call, this guards worker memory; coverage still counts against the document's real thread total, so exceeding it is reported, not hidden. -
✅ Stale takeover leases on
updated_at, notcreated_at(post-review round 2) — every step a live run takes stamps that column, so a run that merely waited in a backed-up queue keeps renewing its own lease instead of being killed mid-generation and re-billed. The conditional kill also reports whether it landed: if the run finished between the dedupe probe and the kill, it is handed back rather than replaced. -
✅ A model call that dies before reporting usage sets
cost = null, not $0 (post-review round 2) — the provider may have accepted and billed the request, so "unknown" is the only honest figure. Tokens keep whatever earlier calls reported. -
✅ A run's scope is frozen at its first assembly, with an
attemptscounter (post-review round 2) — a transient retry re-reads live data, so overwritinginputwould leave the terminal row describing the last attempt while its accumulated spend covers them all. -
AI runs are not written to
audit_logs—ai_runsis itself the append-only cost/audit history (SPEC §16), and the AC asks for namedLogevents. Revisit if the M3.8 activity feed should surface generations. (S)
Decision log (M4 agent tokens #131, 2026-08-18)
- ✅ Agent Tokens are Sanctum personal access tokens under a domain model —
personal_access_tokenspublished verbatim (same hashing, samefindToken()), withApp\Models\AgentTokenregistered as Sanctum's model so the ubiquitous language reaches the code and Policy discovery has something to find. CONTEXT.md gains Agent and Agent Token; SPEC §16 names the table. - ✅ REST v1 refuses the credential, not the principal —
RejectAgentTokenAuthwraps the whole/api/v1group and rejects any request carrying a bearer token before the guard looks it up, rather than checkingcurrentAccessToken()after authentication. Rationale: it is the only version of the rule that cannot be defeated by a route resolving its user lazily, and it makes "tokens cannot mint tokens" fall out of mint/revoke living on REST. - ✅ The rejection is ordered first in the middleware priority list — without it
SubstituteBindingsresolves route models before the credential is refused, so a token holder could tell an existing id (401) from a missing one (404) across every v1 resource. Ordered first, a token-bearing request touches no session, no rate limiter, and no database row. - ✅ Workspace scope is an exact
workspace:{id}ability, checked in the shared Policy trait (eng review §2) —AuthorizesWorkspaceMembershipis now the single membership seam (SharePolicy, ProjectPolicy, IntegrationPolicy, TrackedRepoPolicy and WorkspacePolicy stopped writing their own query), and reviewer-via-share reach plus own-authorship reach are gated by it too, so a token can never ride its owner's identity into another workspace.scopedToWorkspace()deliberately ignores Sanctum's*wildcard: an unscoped token must not read as a scoped one. - ✅ Mint/list/revoke require full workspace membership (eng review §7) — in invisible-tenancy v1, "owns a personal workspace" IS that test, and it denies M2's reviewer shadow users and demo-share visitors structurally rather than by enumerating them. Proven in the IDOR matrix.
- ✅ Resolved in #135 (MCP server):
mcp.enabledships as a config flag (default ON, independent of the AI gate), the capability endpoint reports it alongsideai.enabled, and the settings surface's agent-token panel is gated on it — with the web reading it fail-closed, so a missingmcpblock (an api from before #135) hides the panel rather than minting credentials for an endpoint that isn't there. The substrate is now exercised through the real token-authenticated route, not a probe.
Known debt (M4 #131 codex gate, 2026-08-18)
- ✅ Fixed at the gate:
POST /logoutaccepted agent bearers. The rule was scoped to the/api/v1prefix, leaving the one root-levelauth:sanctumroute open — a human-only action reachable by an agent, and a token-validity oracle (Sanctum stampedlast_used_at). The refusal is now prepended to BOTH route groups, so accepting a token is a positive capability exactly one surface opts into (#135's MCP group, viawithoutMiddleware). The IDOR sweep now enumerates EVERY registered route, not a URI prefix; the only exemptions are/upand/storage/{path}, and the test proves they authenticate nothing before skipping them. - ✅ Fixed at the gate: the refusal ran after Sanctum's stateful wrapper.
SanctumServiceProvider::configureMiddleware()prependsEnsureFrontendRequestsAreStatefulto the middleware priority list at boot, which sorted the session/CSRF stack in front of the refusal — a bearer request with a first-party Origin opened a database session and hit CSRF (419) before being refused.AppServiceProvider::bootnow hoists the refusal the same way, after Sanctum's provider. Asserted twice: on the resolved pipeline order, and behaviourally as zero database queries on a rejected request. - ✅ Fixed at the gate: mint + audit are one transaction (an audit failure must not strand a live token whose value nobody received); revoke writes its trail through
recordSafely(security-first — a dead log sink must not resurrect a revoked credential) and only the request that actually deleted the row records the event; the mint response carriesno-store; the reveal box gained a dismiss action; the settings list distinguishes "failed to load" from "no tokens";tokenReachesWorkspace()fails closed on any persistent token that is not anAgentToken; a foreign token id is denied as NOT FOUND so ids disclose nothing; and a 50-token per-member cap bounds the list. - Dismissed: "authorship-based reach should require live membership." The gate flagged
authorOf()andownsSubjectInReachableDocument()for granting lifecycle/triage/own-comment capability without current membership. That is deliberate M2 behavior with named tests (ThreadCommentTest::test_non_member_document_author_can_triage_and_moderate_comments,..._non_member_thread_creator_...); tightening it is a product decision, not a #131 fix. Both paths ARE now credential-scoped, so no agent token can use them cross-workspace. Revisit as its own ticket if stale authorship reach is judged wrong. - ✅ Resolved in #135: revocation is now linearizable against an in-flight write. Every MCP write re-reads its own token row
FOR UPDATEinside the write transaction (AgentTokenService::revalidateForWrite, threaded intoCommentThreadService::create/replyas a guard closure), so revoke and write serialize on the same row and "no write commits after revoke returns 204" holds wherever row locks exist. Asserted three ways: a revoked-after-authentication write is refused and persists nothing; the query log proves the revalidation runs inside the transaction and before the insert (engine-independent); and the two-connection barrier — the only assertion that needs real locks — is gated to MySQL/Postgres and skipped on the suite's in-memory SQLite, whose second connection is a different database entirely. An unlocked pre-flight check runs first, before the idempotency lookup and before anchor validation, so a revoked credential can neither replay a key into someone's existing comment nor trigger a projection refresh (codex gate, 2026-08-18). - Accepted: the token list is not DB-paginated (SPEC §17 says every list endpoint is). It is owner-scoped and now hard-capped at 50 rows, matching the
GET /integrationsand document-shares precedent. Fold into a general list-pagination pass if the cap ever rises. (S) - Accepted: agent tokens never expire (
sanctum.expirationstays null). Bounded per-agent expiry was not specced for v1; revocation is the control. Revisit alongside fine-grained agent permissions (SPEC out-of-scope list). (S)
Known debt (M4 #131 codex re-review, 2026-08-18)
- ✅ Fixed on the second pass: the revoke id oracle is fully closed — the id resolves through the caller's own tokens and the Policy denies a foreign token as not-found, so "not yours", "never existed", and "you are a reviewer" are one byte-identical 404 (the first pass still leaked through differing 404 bodies and a 403-for-existing split); the settings list is generation-guarded so a slow GET can no longer delete a just-minted token or resurrect a just-revoked one; a new mint attempt retires the previous token's plaintext before it starts (two credentials on screen is how the wrong one gets copied); the per-member cap check moved inside the mint transaction on locked rows; the cap's 422 surfaces the API's own message instead of the name-length hint; and
aria-invalidon the name field is driven by mint failures only, not by copy/revoke/network errors. - Note for #135: the agent-token revoke route deliberately resolves its id through
$user->tokens()rather than implicit model binding. That is binding, not authorization —AgentTokenPolicystill decides — and it exists so the 404 bodies match. Keep the pair together if the route is ever refactored. - Accepted: the per-member cap is best-effort on SQLite, which has no row locks, so two concurrent mints at the ceiling could both land. It is a sanity ceiling on an owner-scoped list, not a billing boundary; MySQL/Postgres deployments get the real lock. (S)
Decision log (M4 post-close amendments, 2026-08-19)
- ✅ AI provider becomes selectable; the gate follows the selected provider's credential (ticketed #140) — revising the shipped v1 pin ("the provider is a hardcoded v1 pin so the gate can never disagree with the provider it guards"). The pin was correct exactly because of that gate coupling; #140 dissolves the coupling the safe way: the operator selects a provider,
ai.enabledderives from that provider's credential, and the shipped invariants survive — a credential is the only thing that can turn AI on,AI_ENABLEDstays a kill switch that can only force off. The seams are already clean (every agent reads the singlekedge.ai.providerknob; zero provider-specific call sites; unpriced models recordcost = null). Claude stays the default and the only certified path; others best-effort (CI runs on the SDK fake regardless); a local Ollama keeps a self-hoster's specs off every vendor. SPEC §14 amended. - ✅ Ask-about-the-doc added to M4 scope (user request, ticketed #139) — select a passage or ask doc-wide, free-form question, ephemeral copyable answer; never persisted as a comment/thread (draft-only trivially — no write path exists). Run type
askin the ledger for cost; reuses the selection→anchor capture and the shared fenced prompt foundation; dedupe-exempt (asks are question-specific — every ask mints a run;throttle:aiis the abuse guard). Single-turn in v1; no MCP ask tool (agents bring their own models). SPEC §14/§16 amended. Both tickets hang off the closed #128 as trailing M4 work.
Decision log (CI e2e speedup, 2026-08-19)
-
✅ The journey pack runs on 4 workers in CI (
workers: isCI ? 4 : 1), cutting the CI e2e step from ~5m30s serial to well under 2 minutes. Spec files parallelize;fullyParallelstays false so in-file test order is preserved — exactly the shape the pack was proven green at. Two server-side changes make this safe:serve-api.shruns the PHP built-in server withPHP_CLI_SERVER_WORKERS=6(a single worker queues overlapping browser+BFF requests until Node's fetch gives up → "API is unreachable" flakes), and the throwaway e2e SQLite opts intoWAL+busy_timeout=5000+IMMEDIATEtransactions (concurrent writers under the driver's defaults threw "database is locked" 28 times in one 4-worker run; zero after).config/database.phpmaps the already-present sqlite pragma keys to env (DB_JOURNAL_MODE/DB_BUSY_TIMEOUT/DB_SYNCHRONOUS/DB_TRANSACTION_MODE) with unchanged defaults — also useful for small self-hosted SQLite deployments. CI additionally caches the Playwright Chromium bundle keyed on the exact@playwright/testversion (~2m30s per run when cold). -
✅ Two latent spec races surfaced by parallelism, fixed in the specs:
share-lifecyclewaited onexpect(revoke).toBeHidden()— but the button's accessible name flips to "Revoking…" on click, so the name-scoped locator "hides" while the DELETE is still in flight and the fresh-context open raced it; it now waits for the API-confirmed "Revoked {date}" line.auth-edgesnow lets the network go idle before signing out (see the debt entry below for why). -
✅ The CI pack serves the production web build — the first CI run at 4 workers ran no faster than serial (5.4m): dev-mode compile-on-demand starved the browsers on the 4-vCPU runner (every test uniformly ~5-8× slower than local). ci.yml now runs
npm run build(NEXT_PUBLIC_API_URL pinned to the :8000 default) before the pack and playwright.config.ts servesnext startin CI / keepsnext devlocally. One build serves BOTH web instances: the self-hosted instance's client never calls the API, so the baked NEXT_PUBLIC_API_URL is irrelevant to it, andnext startreads the per-process server-side API_URL at runtime. This revises the 2026-07-13-era deferral ("rebuilding costs more than it proves for this single handshake journey") — true for one journey, false for 47. -
✅ Prod-mode runs exposed a real thread-rail bug, fixed in the app:
reloadThreadshad no stale-response guard, so a straggling mount-time list fetch could resolve after a post-comment refresh and clobber the fresher list — a just-posted comment silently vanishing from the rail (exactly what a user on a slow connection would hit). Now only the latest-issued reload may commit (reloadSeqRef). Surfaced as a ~1-in-3 i18n-journey flake at 4 workers; 8 consecutive green 46/47 runs after (the 47th is the known local kroki.io debt).
Known debt (CI e2e speedup, 2026-08-19)
- Logout races in-flight authenticated requests (session resurrection) — real app behavior, found via a 4-worker trace: an authenticated request still in flight when
POST /logoutlands re-saves the old session row at request terminate (Laravel persists the session after the response), and its response re-sets the stale session cookie — so the browser can end up signed back in after a completed logout. The doc page's fan-out (threads, shares, AI panel) makes this window real. The journey quiesces before signing out because it asserts the guard, not this race; a product-level fix needs something like a session generation/epoch check at auth time. (M)
Decision log (AI timeout budget #153, 2026-08-25)
- ✅ The agents' HTTP timeout is DERIVED from the job budget, never a second number —
App\Services\AI\AiRunBudgetnow owns both clocks an AI run races:job()iskedge.ai.job_timeout(the queue's terminal per-attempt ceiling, unchanged at 300s) andhttp()is that budget minus a 30-second safety margin, resolved at call time so anAI_JOB_TIMEOUToverride moves both together. laravel/ai defaults an agent to 60 seconds (Promptable::getTimeout: prompt argument →timeout()method → the timeout attribute → 60) and none of the six agents set one, so every generation in the 61–300s band died on the socket inside a job that had budgeted five minutes for it. The attribute cannot follow config, so thetimeout()method is the only seam that can derive — one concern,TimesOutInsideTheJobBudget, used by every agent.http() < job()holds unconditionally: a span at or under the margin halves rather than subtracting to nothing, and the attempt ceiling floors at two seconds so a nonsenseAI_JOB_TIMEOUT(0, negative, unparseable) still leaves a whole second below it. The guard test discovers the agent classes from the directory, recursively, and resolves the number through the vendor's own path, so a seventh agent added without the concern fails the suite the day it lands. - ✅ The attempt owns the clock, not the worker's config (codex gate) — reading config live would have broken the relation in two ways a constant cannot fix. Config drift: a queued job carries the ceiling it was DISPATCHED under, so a budget raised after dispatch (a deploy while runs sit in the queue) would hand one model call more time than the attempt running it has, and the queue rather than the classifier would do the killing. Chunked runs: several sequential model calls share one attempt, and a per-call constant cannot bound their sum — after a slow first chunk the second would still ask for the full budget with almost none of it left. So
GenerateAiRunJobwraps generation inAiRunBudget::forAttempt($this->timeout, ...), and inside that scope the clock counts DOWN against a deadline: the next call gets what the attempt actually has left, minus the margin, and a call that starts with nothing left fails fast on our terms. The scope restores on the way out, exceptions included, so a long-lived worker never leaks one run's deadline into the next. - ✅ A client-side generation timeout is DETERMINISTIC, not
provider_unreachable— the decision the ticket asked for, and the more consequential half. Laravel raisesConnectionExceptionfor everything cURL calls a connection error, and cURL 28 sits in that list, so a generation that merely outran our own transfer clock arrived wearing the same coat as a refused connection: transient, back off 30s, re-send a ~32k-token prompt into the identical wall (preview run 5, 2026-08-25 — 2m51s and three billings to fail). It is now split on the wording that actually distinguishes them: "Operation timed out after N milliseconds with 0 bytes received" is the transfer clock — the provider answered our SYN, was working, has already billed the prompt, and may finish it server-side after we hang up — and lands the run failed with a newgeneration_timeoutcode whose sentence asks for a shorter question or a smaller selection, surfaced by the existing per-turn failure UI which rendersrun.error.messageverbatim. "Connection timed out" (the connect phase), "Could not resolve host", "Connection refused", TLS failures, and anything unrecognized keep the transient reading and the backoff, because nothing was reached and nothing was billed. Why deterministic beat transient: a retry can bill the same prompt twice for one answer nobody sees; the clock is now the whole job budget minus the margin, and a backoff of seconds does not make the next generation shorter; genuine provider slowness already has its own honest transient codes (overloaded, 5xx, rate-limited); and it keeps faith with the sibling condition, since the job's own timeout 30 seconds later is terminal too — the two nearly identical conditions no longer behave in opposite ways depending on which clock happened to win. Both directions are tested at the classifier and again at the job, where the deterministic path proves it does not rethrow (a rethrow is the retry). The rest of the lifecycle contract is untouched and pinned: 202 plus poll, three tries, backoff of 10s/30s/60s, fail-on-timeout. - ✅ The worker polls at
--sleep=1(api/docker/entrypoint.sh) — every AI turn a person is watching paid up to three seconds of idle-poll latency before the worker even looked at the queue. One database poll per idle second is the trade. - ✅ The queue's reservation lease is derived from the same budget (codex gate, second pass) — the third clock, and the one nobody configures on purpose.
retry_afterwas still the stock 90 seconds while an AI attempt may run 300, and Laravel's rule is absolute: a lease shorter than the longest job hands a still-running job to a second worker.ShouldBeUniquededupes DISPATCHES, not redeliveries, andmarkRunningdeliberately lets arunningrow be re-claimed (a hard-killed worker has to be able to hand the run back) — so a slow generation would have been re-reserved twice and re-billed each time. This was latent before, because the 60s socket ceiling kept nearly every attempt under the lease; raising the ceiling is what would have made it fire.config/queue.phpnow floors every driver's lease atAI_JOB_TIMEOUTplus a minute, and an operator's own value can only raise it. - ✅ A model call that dies mid-flight leaves the cost UNKNOWN on every path, not just the chunked ones (codex gate, second pass) — the digest and improve-prompt generators already nulled the cost when a call died, honoring the recorded decision from #130. The single-call paths (ask, reply draft, thread summary, split) did not, so their rows landed failed at the
$0a run is created with — a confident understatement of a request the provider may well have billed, and most likely of all on a timeout, where the model was demonstrably working. The rule now lives once, inStructuredCall, around the outbound call. - ✅ The timeout signature requires the byte count, not just the words (codex gate, second pass) — matching "Operation timed out" alone would have pulled a proxy or non-cURL transport that never reached the provider into the terminal bucket. The byte clause is the half that says a transfer was under way, so both are required and everything unrecognized keeps the transient reading.
Known debt (AI timeout budget #153, 2026-08-25)
- The preview verification is post-deploy, not covered by tests — no suite makes a live provider call (the SDK fake never sits on a socket), so the tests pin the RESOLVED CONFIGURATION and the classifier mapping, not wall-clock behavior. The ticket's last acceptance criterion — an ask over 60s completing on attempt 1, no cURL 28 in worker logs,
ai_runswall time equal to generation plus fixed overhead — is verified on the preview after this deploys. (S) unparseable_outputon preview run 5's five-second third attempt is still unexplained — a structured-output correctness bug, not a latency one; recorded out of scope by #153 and worth its own ticket (repro: ledger run 5, workspace 4, document 99 on the preview). (M)- The ask still ships ~24k tokens of document context every turn — roughly a dime and a slow first token per turn, and the reason a long ask can approach these clocks at all. Ties into the existing proximity-to-selection debt; streaming (accepted debt in
docs/specs/m4-ai-agents.md) remains the real cure for perceived latency. (M)