Kedge

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_version on 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) /design-explore the comment gutterdone 2026-07-03: 6 variants explored; "Protocol Rebuild" approved (clean-room Protocol aesthetic). Design language codified in docs/DESIGN.md; canonical mockup docs/designs/review-page.html; losing variants deleted.
  • P1 (S) Spike: port hypothes.is anchoringdone 2026-07-20 (M3): exact→fuzzy→orphan ladder shipped (#76/#77) on @sanity/diff-match-patch, golden-corpus-validated.
  • P1 (S) M0 spike: validate Fumadocsdone 2026-07-03: VALIDATED (web/ in repo, run npm run dev). Fumadocs 16 + Next 16 renders .md and .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 — langAlias workaround in source.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 synthesize title/description at import; (c) M2 work identified: sticky/anchor-aligned rail, sidebar "Threads" nav group.
  • Diagram rendering spikedone 2026-07-03: VALIDATED, then revised same day. Final state: Kroki is the sole diagram engine (decision below). remark-diagrams converts any fence on the engine allowlist (~20 engines) → <KrokiDiagram/> before Shiki, for both .md and .mdx; async RSC → Kroki GET (deflate+base64url), fetch-cached, KROKI_URL override, 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 reportingdone 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/domaindone 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/kedge taken → org kedgehq. Repo, docs, web app, and dogfood copies all carry the name.
  • USER ACTION P1 (S): register kedge.review + kedge.ink (and kedge.md if free); create the kedgehq GitHub 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 forces NODE_ENV=production (#14). next build only defaults NODE_ENV to production when it is unset; an ambient NODE_ENV=development (common in dev shells / some Docker base images) leaks through, so Next bundles React's development build. Prerendering the synthesized /_global-error page against dev React then crashes with Cannot 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 custom global-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-independent app/global-error.tsx as 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_versions per SPEC §16 (backed enums DocumentStatus/LifecycleStatus/SyncStatus/DocumentFormat/SourceType, (document_id, content_hash) unique, un-constrained current_version_id pointer — a hard FK there would be circular with document_versions.document_id). POST /api/v1/documents → Policy → importingImportDocumentJob (ShouldBeUnique) → 202; GET /documents/{id} polls; POST /documents/{id}/retry for 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.
  • Connector interface extended with sourceType(): SourceType beyond SPEC §5.1's four methods, so each connector owns its documents.source_type value and the registry stays additive for GitHub/Confluence (#22/#23). integration_id column exists (nullable, no FK) for the PAT connector.
  • Interim markdown renderer (web/lib/render-markdown.tsx): unified + remark-rehype without allowDangerousHtml (raw HTML/<script> dropped, never parsed) + an href/src scheme allowlist (strips javascript: links), output as real React elements (no dangerouslySetInnerHTML). 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.
  • DocumentResource is unwrapped ($wrap = null) to match M0's CurrentUserResource house shape; AuthorizesRequests added to the base Controller so every resource controller can $this->authorize().

Decision log (M1 normalization, 2026-07-14)

  • Normalization shipped (#19, SPEC §5.2). Lives in its own service App\Services\Import\Normalization (Normalizer orchestrator → HtmlNormalizer + ImageReHoster + SvgSanitizer), invoked from DocumentImporter at 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 recovered strip_tags text + 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, and javascript: URLs before league/html-to-markdown (+ its TableConverter for GFM tables) ever sees them. enshrined/svg-sanitize for 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, default public served via the /storage symlink; S3/R2 on SaaS). Every referenced image is fetched through the existing GuardedFetcher (SSRF guard reused, not re-implemented), stored content-addressed at media/{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 to web/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).
  • Relative link hrefs absolutize against the source (#50, SPEC §5.2). New LinkReWriter pass in the Normalizer pipeline, beside ImageReHoster and reusing the same GuzzleHttp\Psr7\UriResolver (RFC 3986) resolution against the connector's finalUrl — 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 into content_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 /x resolves against the source origin (documented RFC-3986 choice).
  • Composes with image re-hosting by construction, not coordination. LinkReWriter runs after ImageReHoster and the two never overlap: images match only ![…](…) (anchored on !), links match only […](…) (negative lookbehind on !) plus […]: … definitions. The badge idiom [![alt](img)](./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/mdx compiles imported MDX in the web server layer; the compiled artifact is cached by content hash. lib/remark-mdx-harden.ts runs on the mdast after remark-diagrams and is the security gate: rejects import/export and non-literal expressions (incl. expression-valued + spread attributes — closing the attribute-channel injection that unist-util-visit never descends into), allowlists JSX to Callout/Note/Warning/CodeGroup/Tabs/KrokiDiagram, rewrites unknown components to a neutral <UnsupportedComponent> box, and sanitizes intrinsic HTML. .md docs keep the #17 renderer (raw HTML dropped) unchanged; the doc page + share page branch through one shared DocumentBody.
  • 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 an mdxJsx*Element node, never a hast element/raw node; hast-util-sanitize only handles element/text/comment/root and silently drops every other node type — verified: dropping rehype-sanitize into the pipeline deletes the allowlisted components too. So the tight schema is enforced in the harden plugin at the mdxJsx level, reusing rehype-sanitize's defaultSchema (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 compiled MDXContent is a live closure (unserializable), so L1 (LRU of the run outcome, keyed by sha256(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, before run()) 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's content_hash), so validation-at-import warms the cache render-at-request reuses.
  • mdx_ok is real and stored (additive nullable document_versions.mdx_ok). The projection endpoint runs the same hardened compile as render to decide mdx_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-time md default) to the projector — the bug that would otherwise skip MDX validation entirely — stores mdx_ok, and logs mdx.compile_failed on false. .mdx is detected at import by URL/filename extension (Normalizer). A false mdx_ok is a fallback, not an import failure: the doc is ready and renders plain markdown + banner.
  • Spike langAlias workaround deleted (web/source.config.ts). Replaced the special-case langAlias: { plantuml: 'txt' } with fumadocs' general rehypeCodeOptions.fallbackLanguage: 'plaintext' — every unknown fence language falls to plain text, not one hard-coded case (SPEC §6.2 / §19; resolves the #17 spike 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). integrations table (provider backed enum IntegrationProvider, github_pat only at M1; credentials encrypted:array cast; nullable meta). Endpoints GET/POST/DELETE /api/v1/integrations behind IntegrationPolicy — 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:array at rest, (b) on the model's Hidden list so serialization can never leak it, (c) absent from IntegrationResource (an independent allowlist), and (d) never logged — the connector sends it only on the outbound Authorization header (curl transport, not logged) and the guard already redacts URLs. The masked listing shows at most a last-4 hint stored in meta at 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) and GithubPatConnector (Bearer token) differ only in auth headers and auth-failure handling. The PAT reader is registered after the public one so plain match() never returns it blindly — it is reached only by source type (the job) or by ConnectorRegistry::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_id realized as an indexed soft reference (no FK/cascade), matching the un-constrained-pointer precedent of current_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 optional DocumentSource::integrationId.
  • Token-revoked path (SPEC §19 registry row): a 401 on a PAT-authenticated fetch → terminal TokenRevokedException (handled like a blocked URL — marked failed at 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 /settings alongside the retry CTA.
  • Web integrations surface: a /settings page in the authenticated shell (linked from the header) hosting an IntegrationsPanel client 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.ts hand-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). Unauthenticated POST /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 shared GuardedFetcher. The whole surface is edition-gated by an EnsureDemoModeEnabled (demo.enabled) middleware that 404s per request when SELF_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_by null. A dedicated SystemWorkspace service firstOrCreates one workspace marked settings.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 because Document::isDemo() (system-workspace membership — the canonical "is a demo doc" signal) runs on every shared-doc read. Chose created_by = null over a synthetic system user: the importer is genuinely anonymous, and a fake user would pollute users + 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 the auth:sanctum 401 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::issue creator made nullable) and returns its URL; the visitor lands on /shared/{token}. SharedDocumentResource exposes claimable for every share and, only when claimable, the document_id + expires_at the 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 new self_hosted boolean on GET /api/v1/config (single source of truth = API env; web carries no SELF_HOSTED var), 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_BYTES config 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-docs scheduled 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 past expires_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 endpoint POST /api/internal/diagrams (outside /v1, not a public contract) → App\Services\Diagrams\DiagramRenderer: renders each diagram once per (engine, sha256(source)), caches the SVG at diagrams/{engine}/{hash}.svg on MEDIA_DISK, returns the /storage URL. 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:8000 in the self-host compose), so DiagramRenderer uses the plain HTTP client with size/timeout caps, not GuardedFetcher (whose https-only + private-IP blocking would reject the internal container). GET payload encoding is zlib (gzcompress, RFC 1950 — matches Node's zlib.deflateSync), not the header-less gzdeflate, 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 run enshrined/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. VerifyDiagramSecret middleware requires X-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 of web/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.ts diagramEngineFor remains canonical for fence detection and the projection's ⟦diagram:engine⟧ tokens; the API's DiagramEngines::ALL mirrors KROKI_ENGINES (hand-written TS/PHP duplication, accepted M1 debt). The web resolves aliases (dotgraphviz, …) 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. /docs is statically generated (SSG) where the API isn't running, so it keeps its build-time component (components/kroki-diagram, direct Kroki fetch → inlined data: 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 name KrokiDiagram in 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 share DiagramFigure (the <img> + click-to-zoom lightbox: accessible dialog, ESC/overlay close, scroll lock, DESIGN.md tokens) and the never-crash DiagramSourceError panel. Unifying onto the API path was rejected: it would either regress the SSG build to error panels (API down at build) or force /docs dynamic (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_failed log event (SPEC §19) + the show-source panel; a cache-miss render logs kroki.rendered (info) so the cache-hit proof is a log count, not a guess. source added to the TrimStrings exempt list (with content) 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 as detail in the render_failed 422 (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 generic diagram service unreachable — the low-level message stays in the log, never on the page. Decision: the API's Kroki GET drops the Accept: image/svg+xml header — 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 (shared lib/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 in api/.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 Playwright webServer) was chosen over stashing fixtures in web/public/ so test content never ships in the deployable.
  • CI renders diagrams through its own Kroki service containersyuzutech/kroki (pinned) + the kroki-mermaid companion it delegates mermaid to, wired by service name on the Actions network; KROKI_URL flows through serve-api.sh into the generated .env.e2e. CI never calls hosted kroki.io; a local run keeps the dev default (kroki.io) unless KROKI_URL is exported. The journey proves the render end-to-end: <img src> asserted against the content-addressed /storage/diagrams/mermaid/<hash>.svg cache and naturalWidth > 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 worker serve-api.sh would 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_URL pinned to IPv4 for the same localhost::1 reason as the rest of the e2e host choices.
  • serve-api.sh dotenv-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_ENV won via the serve passthrough), but it silently swallowed QUEUE_CONNECTION/PROJECTION_URL at 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 of docs/specs/m0-scaffold.md (auth surface) and docs/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.ts uniqueIdentity) and creates its OWN documents — no fixture user, no shared doc, no order dependence. playwright.config.ts keeps workers: 1 / fullyParallel: false (a scope choice: flat CI resource use, deterministic output), but the pack is proven green at --workers=4 and --workers=4 --fully-parallel locally, so raising it later is a one-line change. Shared steps and the fixture-URL registry live in e2e/helpers.ts + e2e/fixtures.ts; new fixture documents (a plain .md, a diagram .md, a warnings .html + a 1×1 .png, two .mdx) under e2e/fixtures/, and a stateless /status/500 route added to serve-fixtures.mjs (traversal-guarded, dependency-free) for the transient-failure and broken-image journeys.
  • CACHE_STORE=array added to the E2E env (serve-api.sh). The pack drives many unique users over one loopback IP, and the cache-backed rate limiters (throttle:auth 10/min/IP, imports/shares/integrations per user-or-IP — AppServiceProvider) key on values that are shared or clustered across independent journeys; with the example's persistent database store 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. array is per-request under php 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-failure is a deliberate split — fail-only, not retry-flip — because of the sync-queue seam (the ticket's escape hatch, taken with cause). import-failure covers 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 runs QUEUE_CONNECTION=sync, so a transient failure — unlike a swallowed block — rethrows out of the synchronous dispatch() inside POST /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 ImportFailed island's "Retrying…" pending flag un-reset (the island persists across router.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; the import-failure spec 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 as ImageReHoster: 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:href remote reference on <image> can survive enshrined/svg-sanitize's removeRemoteReferences (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 M6reversed 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) — Connector interface 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 documentADR 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: the kind/parent_version_id columns + 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 · MCP badge; 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: a comments meta/agent_name column + a badge variant. Revisit when multi-agent workspaces are real. P3 (S)
  • Workspace AI spend capthrottle:ai limits request rate, not spend; a chunked digest costs many× a normal run, and on the SaaS one workspace could burn the shared-key budget. The ai_runs ledger 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 in ai_run.started logs first, act on evidence. P3 (S)
  • Per-share allow_anonymous toggle — SPEC §16 lists allow_anonymous on the shares entity 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 via PageContainer; 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 one max-w-7xl container, 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 fired click on document highlights (found by the layout e2e probe; unit-tested in anchor-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 livenessRESOLVED (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/i over sync_error prose (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-readable sync_error_code in the document resources and key importNeedsReconnect off 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 usePollUntilSettled hook + 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 /me 500 for workspace-less sessions — CurrentUserResource null-derefs personalWorkspace(); 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 affordancestore() 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 source working 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_failed outcome) 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 queueRESOLVED (M3.6 branch review, 2026-07-21): the scan trigger now atomically flips the record to pending before 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_URL local-container step. (S)

Known debt (M3.6 branch review, 2026-07-21)

  • GitHub response policy lives twiceGithubRepoClient (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 shared Services/Github collaborator/trait for both. (M)
  • usePollUntilSettled deps are caller-ownedRESOLVED (#98, 2026-07-23): the hook now holds poll/onSettled in refs refreshed every render and reads them at tick time, and takes a single key (the poll target) as its only dependency. The stateful loop is extracted as startPollLoop (injected scheduler) so the no-stale-closure and restart-on-key-change guarantees are unit-tested without a DOM. All four consumers migrated to key:; the caller-owned effect-deps arrays and their eslint-disables are gone.
  • Scan status remains triple-encodedlast_scan_status column, scan_error column, and status/error embedded in last_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-explore round: 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 (warm stone-50 register; 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 passRESOLVED (M3.7 #99/#100, 2026-07-23): shipped as the Open Harbor core + chrome sweep (when scheduled): self-host Space Grotesk woff2 in web/ (no runtime Google Fonts fetch), flip default theme to light, re-map the --color-fd-* overrides to the amended tokens (stone-50 page, -700 light 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); usePollUntilSettled deps 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 (handleCreated append) races refreshProjects (wholesale replace) in workspace-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 from snapshot without a row lock — concurrent same-workspace renames could record a stale from. 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.created is deliberately excluded — a thread open also emits comment.created for its first comment, so exposing both would double every new-thread action; comment.created alone covers the user story's "replies". suggestion.declined is 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 is ref/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-document reanchor.completed digests 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.scanned with a 2A project/repo display snapshot in TrackedRepoScanService::recordAudit, then allowlist it with a project target. Belongs to the tracked-repo module, not #111.
  • workspace.renamed now snapshots actor_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: ActivityTargetResolver scopes 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 skips ImportDocumentJob::dispatch, stranding the document at importing), ApprovalService::revoke, and DocumentLifecycleService. Pre-existing (unchanged from main; M3.8 only enum-typed the action) and outside M3.8's feed write-side scope. Move each onto the recordSafely boundary — 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.scanned carries 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}/content overwrites documents.source_meta with the new paste and dispatches the same ResyncDocumentJobResyncService. The shared UploadConnector reads the body back, so normalization + warnings, content-hash dedupe (§5.2), the re-anchor ladder, approval staleness, and the one reanchor.completed digest (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 /resync body: uploads get an Update content affordance, URL docs keep Re-sync untouched (a URL doc POST /content → 409). A content update needs a size-capped body /resync has none of, and different feature-gating — overloading resync by source type would fork one action's behavior. The job and service stay shared; only the trigger differs.
  • source_meta overwritten so a retry re-imports the LATEST paste: the new body (+ optional replacement title) replaces source_meta before 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 flips current_version_id only after the target anchors are durable.
  • Authz reuses the resync policy (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 CapsPastedContent trait: 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 updateContent policy = authorOf (not the membership-gated resync), exposed as the update_content capability the web gates the affordance on, with a same-workspace non-author 403 test; content replacement is a stronger capability than review, matching updateLifecycle. (2) Title preserved, not re-set — the surface versions the body only; the controller keeps the author's source_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 (like retry()), 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 (ResyncDocumentJob unique per document): a second update dispatched while the first job is mid-flight is suppressed, so if the first job already read the old source_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-patch match, which throws "Pattern too long" for pattern.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/reanchor 500s 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 >32 pattern (or a too-long window) as no-match → orphaned, or chunk the pattern, instead of calling dmpMatch unguarded. 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 as aria-hidden decoration only. OG card ships as web/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_path is the scan diff key, source_type/source_url on 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 (AbstractGithubBlobConnector URL 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=path tiebreak is not fully index-coveredorderBy('tracked_path')->orderByDesc('id') leans on the standalone tracked_path index for the sort; the id DESC term 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 for sync_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.ts aria-labels ("Open thread"/"Open threads") are English — raw-DOM builder outside the component tree, and e2e/i18n-shared.spec.ts asserts 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.json duplicate two keys (current, backToDocument) across the #123/#126 lane boundary — consolidate into diff.json and drop the documents.json copies in all four locales. (S)
  • Pre-existing: next build fails prerendering /_global-error (useContext null) — verified identical on main with 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 runs next build. (S)
  • /docs renders 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 /docs matters, split root layouts by route group at Launch tail. (S)