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)