--- title: "The codebase" type: reference tags: [signpost, docs, reference] proposed_by: tok_bc09b340db294c709763789154d8113d model: kmem-docs-publisher extracted_at: "2026-10-08T22:25:55.179Z" --- # The codebase **Read this before you change anything.** It is the repository described from the code rather than from the other documents: what is in each package, the six abstractions everything else hangs off, the exact file to open and the exact example to copy for each of six common changes, and thirteen traps that have each cost somebody an hour. Companion documents: [`ARCHITECTURE.md`](./ARCHITECTURE.md) is why the system is shaped this way and what each invariant prevents; this document is where things are. `RELAUNCH_NOTES.md` at the repository root is what is broken right now. Every path below is repo-relative. A line number locates a name; read the name, because the line moves. --- ## 1. Workspace layout `pnpm-workspace.yaml` declares three globs: `apps/*`, `packages/*`, `e2e`. Seven workspace packages plus the root: | Package | Path | Private | Purpose | Depends on | | --- | --- | --- | --- | --- | | `signpost` (root) | `/` | yes | scripts, tooling, `wrangler.jsonc` | — | | `@signpost/core` | `packages/core` | yes | pure logic: Markdown model, blocks, inline syntax, chunker, tenancy rules, the nine-step retrieval pipeline | nothing in-repo | | `@signpost/db` | `packages/db` | yes | Drizzle schema + D1 migrations | nothing in-repo | | `@signpost/worker` | `apps/worker` | yes | Hono app, MCP server, Quick Connect, REST, auth, Durable Objects | `core`, `db` | | `@signpost/web` | `apps/web` | yes | React SPA: browse, note, recall, review, settings, landing | `core` | | `@signpost/cli` | `packages/cli` | yes | the `init`, `ingest`, `export` and `import` commands | `core` | | `@signpost/agent-kit` | `packages/agent-kit` | yes | Claude Code plugin, Agent Skill, hooks | `core` | | `@signpost/mcp` | `packages/mcp` | yes | the stdio bridge a local client speaks to | — | | `@signpost/e2e` | `e2e` | yes | Playwright specs against a running instance | — | Non-package directories: `eval/` (plain `.mjs`, not a workspace package, imported by `node` directly), `brand/` (fonts, the woff2 build and `tokens.json`), `design/` (the design contract, the screen specifications and `tokens.css`), `scripts/`, `docs/`, `.github/workflows/`. Build graph is TypeScript project references, not a task runner. There is **no `turbo.json`**. `tsconfig.json` at the root is a solution file listing every project reference; `pnpm typecheck` is `tsc --build --force` over all of them, tests and config files included. Dependency direction is strictly one-way: `core` → `db` → `worker`. `packages/core` imports nothing from the repo. `packages/db` imports nothing from the repo. Nothing imports `apps/worker`. ### 1.1 The `signpost` names are namespaces, and they stay A machine resolves each of these names. A customer never reads one. They are not brand names and they do not change: the `signpost://` MCP resource scheme, the `SIGNPOST_*` environment variables, the `@signpost/*` package names, the `sp_` token prefix, the `sp-` CSS prefix, the `waymark` field in API responses, and the `signpost` names of the Worker, the D1 database, the R2 bucket, the Vectorize index and the queues. A rename buys a customer nothing and costs one real failure mode: a missed reference that reads `undefined`. Rename a name a person reads. Leave a name a machine resolves. --- ## 2. `packages/core` — the pure layer Zero Cloudflare imports and zero `node:` imports, enforced two ways: `tsconfig.base.json` sets `"types": []` so nothing is ambient by default, and `biome.json` adds a `packages/core/src/**` override with `correctness.noNodejsModules: "error"` and `style.noRestrictedImports` banning `cloudflare:*`, `@cloudflare/*`, `agents`, `agents/*`, `drizzle-orm*`, `hono*` and `@signpost/db`. ### 2.1 Exports map (`package.json` `exports`) | Subpath | File | Notes | | --- | --- | --- | | `.` | `src/index.ts` | flat barrel over blocks, chunk, inline, markdown, ref, types | | `./types` | `src/types.ts` | the vocabulary | | `./ref` | `src/ref.ts` | `kmem://` grammar | | `./markdown` | `src/markdown/index.ts` | source-preserving parse/render/edit | | `./inline` | `src/inline/index.ts` | wikilinks, tags, refs, code mask | | `./chunk` | `src/chunk/index.ts` | chunker + the single token counter | | `./identity` | `src/identity.ts` | the one rule that turns an account into a name a person recognises | | `./membership` | `src/membership.ts` | the role list, parsed in one place, and the rule that an organization always keeps an owner | | `./lifecycle` | `src/lifecycle/index.ts` | typed notes, temporal frontmatter, the acceptance rung and the clamp rules | | `./collab` | `src/collab/index.ts` | what the browser and the Durable Object must agree on to edit one note together | | `./permissions` | `src/permissions/index.ts` | capabilities, scopes and the visibility ladder | | `./tenancy` | `src/tenancy/index.ts` | `RESERVED_SUBDOMAINS`, `hostFor`, `rootDomainOfHost` | | `./ai` | `src/ai/index.ts` | the `AiProvider` port and its adapters, for the calls that are a chat completion rather than retrieval | | `./retrieval` | `src/retrieval/index.ts` | **deliberately not in the flat barrel** | `src/index.ts` (40 lines) re-exports six modules flat and explains the rule: every name in the flat surface is unique, because two functions called `deriveBlockId` that disagree would give two answers to "which block is this", and that answer decides where a provenance row lands. `retrieval` is behind its own subpath because it has an honest collision: `IndexHealth` in `src/types.ts` is the two-field wire object on a `ContextPack`, `IndexHealth` in `src/retrieval/ports.ts` is the interface `apps/worker` implements to produce it. Stated at `src/retrieval/index.ts:38-46`. ### 2.2 `src/types.ts` (1,013 lines) — the vocabulary Everything on the wire, snake_case where it is public API. Ids: `NoteId`, `BlockId`, `ChunkId`, `Permalink` (all `string` aliases). Enums with a runtime array and a type guard beside each: `NOTE_TYPES` / `isNoteType` (`note | decision | convention | state | reference`), `BLOCK_KINDS` / `isBlockKind` (10 kinds), `AUTHOR_KINDS` (`human | agent`), `WRITE_SOURCES` (`web | mcp | webmcp | quickconnect | git | ingest`), `VERIFICATION_STATES` (`unverified | verified | stale | superseded`). Structures: `SourceRange`, `Observation`, `Relation`, `Block`, `Frontmatter`, `ParsedNote`, `Provenance`, `Chunk`, `AuditEntry`, `Actor`, `ChangeBase` and the four `Change` variants, `CommitResult`, `ClaimProvenance`, `Claim`, `Conflict`, `Gap`, `IndexHealth`, `ContextPack`. Function types: `Sha256`, `Clock`, `TokenCounter`. ### 2.3 `src/ref.ts` (478 lines) — addressing, and the one slug rule Three shapes, one grammar: ``` kmem://backend/auth-decision NoteRef kmem://backend/auth-decision#blk_7f3a1c2d BlockRef kmem://backend/auth-decision/depends_on/* RelationRef ``` **`slugFromTitle` and `MAX_SLUG_LENGTH` live here too, and they are the only copies.** A title becomes an address in three places — MCP `remember`, Quick Connect's `POST $BASE/remember`, and the web app's new-note form — and each once held its own rule, and no two of them agreed on a 64-character title or on `日本語`. The rule sits beside `normalizePermalink` because that is the grammar its output has to satisfy, and in `packages/core` because that is the only package all three doors can import, browser included. `apps/worker/test/invariants/one-slug-rule.test.ts` fails if a fourth copy appears. Two more schemes parse to the same refs and are normalised away: `sp://`, the scheme from the time the product was called Signpost, and `memory://` (Basic Memory migration). `formatRef` and `refFor` always emit `kmem://`. A stored row or a note can still say `sp://`, so every reader of a stored target uses `refSchemeLength`, `withoutRefScheme`, `storedRefsFor` or `STORED_REF_SCHEMES`. `apps/worker/test/invariants/one-ref-scheme.test.ts` refuses a second spelling of `sp://` in any `src` directory ([ADR 0014](./ADR/0014-the-product-is-called-kmem.md), amendment). The trailing `/*` is what disambiguates a relation ref from a note ref whose permalink contains slashes — `kmem://a/b/c` is the note, `kmem://a/b/c/*` is note `a/b` relation type `c`. Exports: `REF_SCHEME`, `MEMORY_SCHEME`, `STORED_REF_SCHEMES`, `ACCEPTED_REF_SCHEMES`, `REF_SCHEME_PATTERN_SOURCE`, `refSchemeLength`, `withoutRefScheme`, `refFor`, `storedRefsFor`, `NoteRef`, `BlockRef`, `RelationRef`, `Ref`, `RefKind`, `RefParseError`, `RefParseResult`, `isCanonicalBlockId`, `normalizePermalink`, `noteRef`, `blockRef`, `relationRef`, `parseRef`, `safeParseRef`, `isRef`, `formatRef`, `refPermalink`. Forbids `/[\s#?*\\]/` in a segment — see the trap in §6 about the gap between this and R2 key rules. ### 2.4 `src/markdown/` — the source-preserving model | File | Lines | What | | --- | --- | --- | | `index.ts` | 85 | barrel; the file header is the best short statement of the model | | `parse.ts` | 288 | `parseNote(source, options)` | | `mdast.ts` | 101 | micromark/mdast boundary; shifts offsets back past a BOM | | `segment.ts` | 225 | the tiling rule — who owns the bytes between two blocks | | `offsets.ts` | 251 | `BOM`, `LineEnding` (`\n`, `\r\n`, **and a lone `\r`**), `normalizeLineEndings`, `detectLineEnding`, `collapseWhitespace` | | `tiling.ts` | 185 | `validateTiling`, `describeTiling`, `TilingGap`, `TilingOverlap`, `TilingReport` | | `edit.ts` | 299 | `spliceBlock`, `appendBlock`, `prependBlock`, `setFrontmatterProperty`, `BlockNotFoundError` | | `frontmatter.ts` | 190 | `parseFrontmatter`, `fenceFrontmatter`, warning codes | | `render.ts` | 32 | `renderNote`, `renderBlocks` — a no-op on an unmodified note by construction | | `sha256.ts` | 212 | synchronous pure-TS SHA-256, re-exported as `sha256Hex` from `blocks/identity.ts` | | `yaml.ts` | 108 | YAML helpers | | `text.ts`, `observation.ts` | 46, 61 | text extraction; the one classifier deciding whether a list item opens with `[category]` | ### 2.5 `src/blocks/` - `identity.ts` (985 lines) — block identity. See §4.4. - `observation.ts` (400 lines) — `- [decision] content #tag (context)`. Exports `parseObservationLine`, `buildObservation`, `parseObservation`, `renderObservation`, `renderObservationLine`, `splitListPrefix`, `splitTrailingContext`, `SUGGESTED_OBSERVATION_CATEGORIES`, `ObservationError`. Two fuzzed round-trip laws are stated in the header. - `relation.ts` (463 lines) — `- depends_on [[target]] (context)` plus bare wikilinks → `relates_to`. Exports `parseRelationLine`, `parseRelation`, `renderRelation`, `renderRelationLine`, `extractWikilinkRelations`, `extractFromBlock`, `DEFAULT_RELATION_TYPE`, `SUGGESTED_RELATION_TYPES`, `RelationError`, `BlockExtraction`. **Invariant I3 lives here in spirit**: an unresolvable target is never dropped. ### 2.6 `src/inline/` — wikilinks, tags, refs, and the code mask `mask.ts` (406 lines) is the load-bearing one. Every inline extractor runs against a mask first and matches only where the mask says "plain". Two flags per character: **opaque** (fenced code, inline code span, HTML comment) and **escaped** (backslash escapes). Documented limits: four-space indented code blocks are *not* detected (the caller has the block kind and should not run inline extraction on a `code` block), and an unterminated `"` (line 91), `MANAGED_END = ""` (line 94), `MANAGED_REGION_MAX_LINES = 200` (line 114). - `projectManagedRegion(existing, rendered)` (line 448) replaces what is between the markers and returns the whole file **with every byte outside the markers identical** — built by slicing the input either side and concatenating, so there is no re-render step that could reflow a table. It re-reads its own output before returning it. - `detectDrift(existing, rendered)` (line 608) is a **separate function, not a flag**, because a hand-edit is never overwritten — it is raised as a proposal. A caller that never asks is visible in review as a caller that never imported the function. The report carries both line arrays, because the proposal has to show a person *what* changed. - `locateManagedRegion` (line 202) enforces three rules: fenced code is ignored (a `CONTRIBUTING.md` documenting the marker syntax is not a file with a managed region); exactly one of each, in order, else `malformed` and the caller refuses; the boundary is the marker itself, not the line it sits on. Selection (`briefing.ts`): `selectConventions` (line 310) reads live `convention` notes in permalink order, up to `MAX_CONVENTION_NOTES = 1,000`, and applies three gates. The opt-in is a frontmatter key `briefing` (`CONVENTION_APPROVAL_KEY`, line 121) accepting `true`/`"true"`/`"yes"`; the note must carry no `signpost:managed` marker in its body (which would nest the region); and gate 3, the one that costs a query, is `isHumanApproved` (line 551) — **every line must stand behind a person**, checked with two bulk queries (block count per note, and provenance) so that a missing provenance row is a refusal rather than a pass. Omissions are reported with an `OmissionReason`, not silently dropped. `renderManagedRegion` (line 614) packs to the line cap; `regionNotices` (line 648) says what was left out. `quickConnectBase(requestUrl, declaredBaseUrl)` (line 193) takes the **origin** from `SIGNPOST_BASE_URL` and the path from the request. A URL's authority is the `Host` header, which is whatever the caller sent, so taking the origin from the request would let anyone who could reach the Worker decide the base printed into a briefing that carries a credential. `BRIEFING_TOOLS` (line 944) is the verb table both the briefing prose and `tools.json` render from. --- ## 5. Extension points — exact files and the example to copy ### 5.1 Change a retrieval adapter One port, one file, under `apps/worker/src/retrieval/`. To replace a port, write the new file beside the others, export a factory that takes the `ProjectContext`, and wire it in `createRetrievalPorts` in `ports.ts` — the one place that names which implementation ships. Copy `lexical.ts` for a D1-backed port and `dense.ts` for a network-backed one, because `dense.ts` also shows how a stage records its own degradation instead of throwing. Two rules bind a new adapter: - **`IndexHealth` is imported, never re-implemented.** Use `liveIndexHealth`, so `/health`, the queue consumer and every `ContextPack` report the same two numbers. - **The model is named once.** `apps/worker/src/embedding-identity.ts` holds the model, the width and the version, and `test/retrieval/embedding-identity.test.ts` scans every Worker source and fails if a second file declares any of the three. A reader embedded by one model against a corpus embedded by another produces a meaningless distance, not a worse one, and the only symptom is that recall quietly gets worse. Nothing outside `apps/worker/src/retrieval/` builds an adapter. `routes/api/recall.ts`, `routes/quick-connect/reads.ts` and `mcp/operations.ts` call `createRetrievalPorts` and know nothing else. The embedding half is a separate seam: `embedChunks` in `apps/worker/src/services/index-queue.ts:387`. Its header states the four steps and the order, and the order **is** invariant I4: run the model, `ctx.vectors.upsert`, **only then** stamp `embedded_at`/`embedding_model`/`embedding_version` through `markChunksEmbedded` (which takes a `WriteToken`, so this function must ask `CommitService` rather than write itself), then `reportEmbeddingPipeline({model, version})` in `http/capabilities.ts:133` so `/health` stops saying nothing embeds. Stamping before the upsert produces the one state I4 forbids. ### 5.2 Add an MCP tool Three files must agree, and the type system enforces two of the three joins: 1. `apps/worker/src/http/bearer.ts` — add the name to `MCP_TOOL_SCOPES` with the `SessionScope` it needs: `read`, `suggest` or `write`. This table is the authorisation half and lives away from the definitions so one place decides which operations write. 2. `apps/worker/src/mcp/tools.ts:238` — add a `ToolSpec` to `TOOL_SPECS`: `name`, `description`, `inputSchema` (a Zod schema declared above), `annotations` (all four hints). The header states the economy rules: say what it does and when to call it and nothing else; no `describe` on a self-explanatory field; **flat objects, never discriminated unions** (Zod renders a union as `oneOf` with a full copy of the shape per branch — `editInput` at line 158 is the worked example, four ops on one object with a `superRefine`); no `outputSchema`; no `title`. `TOOL_NAMES_MATCH_SCOPES` (line 327) fails to typecheck if step 1 was skipped. 3. `apps/worker/src/mcp/operations.ts` — add the implementation and a `case` in the exhaustive switch in `apps/worker/src/mcp/server.ts:170`. A missing case is a compile error. Then run `pnpm mcp:footprint` to update the README line, or CI's `mcp:footprint:check` fails. `test/mcp/tools.test.ts` asserts the tool count and that `readOnlyHint` agrees with `MCP_TOOL_SCOPES`; `test/mcp/footprint.test.ts` measures the real `tools/list` payload inside workerd against `MCP_TOOL_TOKEN_BUDGET = 3500` (`http/capabilities.ts`). Every tool definition is paid for on every turn, so `node apps/worker/scripts/tool-footprint.mjs` prints the current cost. Trust that figure over any number written in a document. Copy `link` (`tools.ts:298`, `operations.ts:962`) for the simplest complete example. ### 5.3 Add a Quick Connect verb Two files, and the record type makes the second mandatory: 1. `apps/worker/src/http/bearer.ts:119` — add an entry to `QUICK_CONNECT_ENDPOINTS` keyed by the path segment: `{ method: "GET" | "POST", need: "read" | "write" }`. 2. `apps/worker/src/routes/quick-connect/index.ts:66` — add the handler to `QUICK_CONNECT_HANDLERS`, which is typed `Readonly>`, so omitting it is a compile error rather than a 404 found in production. Handlers go in `reads.ts` (GET, copy `browse` at line 390) or `writes.ts` (POST, copy `verify`). `mountEndpoint` (`guard.ts:290`) is the only way to register one and it reads the method and scope from the same table the authenticator uses, so a route cannot authenticate differently or skip the rate limit. `test/routes/quick-connect/guard.test.ts` walks `QUICK_CONNECT_ENDPOINT_NAMES` and asserts every one refuses an unknown, expired and revoked link — so a new route is covered before it has its own test. Add it to `BRIEFING_TOOLS` (`services/briefing.ts:944`) if agents should be told about it. ### 5.4 Add a REST route `apps/worker/src/routes/api/index.ts`, `API_GROUPS` — write `routes/api/.ts` exporting `export const : SignpostRouter = new Hono()`, then add one `[prefix, group]` pair to `API_GROUPS`. The request id, redacted structured log, security headers, `Cache-Control: no-store`, the `http` error dialect, CSRF (`sameOriginRequired`) and `requireSession` are all already in the chain; `c.var.project` is the resolved `ProjectContext`. Two suites will fail until the new group is documented, and both are deliberate: `test/routes/api/auth.test.ts` walks `API_GROUPS` and asserts every prefix refuses an anonymous request, and `test/routes/api/reference.test.ts` asserts every mounted group has an entry in `API_ENDPOINTS` (`routes/api/reference.ts`) and that `docs/API.md` is byte-identical to what those schemas render — regenerate it with `node apps/worker/scripts/generate-api-docs.mjs`. That generator runs the TypeScript sources through Node's type stripping, so a **constructor parameter property anywhere in the new route's import graph** breaks it with `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`; write the field and assign it in the body instead (`retrieval/embedder.ts`, `EmbeddingError`, is the worked example). `/api` is authenticated by a cookie and refuses a session link, with exactly one exception: `POST /api/reindex`, mounted with `acceptSessionLink("write")` above `requireSession` in the same file. `routes/api/session-link.ts` carries the argument for why that one path takes a link and what it may not do; a second entry means making that argument again. Registration-order rule (stated at `index.ts:44-57`): a permalink contains slashes so reads end in a wildcard, and a wildcard shadows anything registered after it. **A wildcard only ever lives inside its own prefix** (`/notes`, `/backlinks`, `/history`), and every command that would collide takes its argument in the body (`/notes/restore`, `/history/restore`) or lives under a prefix with no wildcard (`/blocks/edit`). `test/routes/api/routing.test.ts` asserts this by creating a note at the address of every command word. Write handlers throw `RequestError` (`http/errors.ts:85`, with `protocolError` / `applicationError` / `retryableError` / `unauthorized` / `forbidden` / `rateLimited` constructors) and never build a `Response` for a failure. **Do not add another `onError`** — there is one error contract. Every mutation is a `Change` handed to `CommitService.commit`; copy `routes/api/notes.ts`. If the route is public API, add it to `API_ENDPOINTS` in `routes/api/reference.ts` and regenerate with `node apps/worker/scripts/generate-api-docs.mjs`; `test/routes/api/reference.test.ts` compares the rendered output to the checked-in `docs/API.md` byte for byte and parses every documented example through its own schema. To add a whole **route group** (not just an `/api` sub-router): write `apps/worker/src/routes/.ts` exporting `router: Hono`, add one line to `ROUTE_GROUPS` in `apps/worker/src/index.ts:200`, and add the mount point to `MOUNT_POINTS` in `http/types.ts:187`. Keep `web` last: it is the catch-all. ### 5.5 Add a D1 migration 1. Edit `packages/db/src/schema.ts`. 2. `pnpm db:generate` (`drizzle-kit generate` in `packages/db`). It writes `migrations/NNNN_*.sql` and updates `migrations/meta/`. 3. **Read the generated SQL.** SQLite cannot alter a primary key, so drizzle-kit emits a table rebuild; `0001_provenance_note_id.sql` is the worked example of hand-editing the backfill (it joins through `blocks` to get a `note_id` the old table does not have). 4. `pnpm db:migrate:local` to apply locally, then `pnpm db:migrate:local` again — CI runs it twice on an empty `.wrangler/state` and requires the second run to be a no-op. 5. Remote: `pnpm db:migrate:remote`, which is `wrangler d1 migrations apply DB --remote`. `0002_auth.sql` is the worked example of a hand-written migration and its header carries the rule in full: **every applied migration is already in the `d1_migrations` ledger of the live database, wrangler applies a migration exactly once by filename and never re-reads one it has applied, so a table added to 0000 would exist on a fresh database and be permanently missing on the deployed one** — a failure invisible until the first sign-in. Auth tables go in `apps/worker/src/auth/schema.ts`, not `packages/db`. ### 5.6 Add a note type `NOTE_TYPES` in `packages/core/src/types.ts:74` is the source, but four other places must change and two of them will not tell you: 1. `packages/core/src/types.ts:74` — the array. `NoteType` and `isNoteType` follow. 2. `packages/db/src/schema.ts:74` — the `notes.type` column's `enum` list. **Not derived from `NOTE_TYPES`**; a mismatch is only caught at runtime. 3. `packages/core/src/retrieval/types.ts:1,701` — `DEFAULT_RETRIEVAL_CONFIG.halfLives` is `Record`, so this **is** a compile error. Choose a half-life in days. 4. `packages/core/src/retrieval/intent.ts:712` — `neutralWeights()` is written as a literal *specifically so that adding a type is a compile error here*; a new type defaulting to `undefined` would produce a `NaN` weight and poison every score downstream. Also check `INTENT_RULES` (`intent.ts:277`) if the new type should have query cues. 5. A migration if existing rows need the value, plus the `type` enum in the SQL. Zod schemas that accept a type (`mcp/tools.ts:123,146`, `routes/api/common.ts:190`, `routes/api/recall.ts:75`) all use `z.enum(NOTE_TYPES)` and follow automatically. `docs/API.md` must be regenerated. --- ## 6. Traps ### 6.1 Never edit an applied migration Every migration already applied is recorded by filename in the `d1_migrations` table of the live database, and `/health` reports how many. Wrangler applies by filename, exactly once, and never re-reads. Anything added to an applied file exists on a fresh database and never on the deployed one. ### 6.2 `meta/_journal.json` and `migrations/` disagree `packages/db/migrations/meta/_journal.json` lists only `0000` and `0001`. Every later migration was hand-written, exists on disk, applies remotely, and is **not** in the journal. Consequence: the next `pnpm db:generate` numbers its output `0002_*.sql`, collides with the existing file, and its snapshot diff knows nothing about any table the hand-written migrations added. Check the filename before you commit anything drizzle-kit emits. ### 6.3 `chunks_fts` is contentless and synced by explicit insert, never by triggers `CREATE VIRTUAL TABLE chunks_fts USING fts5(text, title, tags, content='', tokenize='porter unicode61')` — migration `0000_initial_schema.sql:135`. Two consequences, both deliberate: rows are inserted and deleted **explicitly by `CommitService`, inside the same D1 batch as the `chunks` write** (via `indexChunksStatement` / `unindexChunksStatement` in `services/fts.ts:206,221`), because triggers would make the sync invisible and untestable; and a contentless table cannot return column values, so search yields `rowid` and `rank` and the text comes from `chunks`. Also: **D1 export does not support a database that contains a virtual table** — [`DEPLOYMENT.md`](./DEPLOYMENT.md) carries the drop-and-recreate workaround. ### 6.4 `packages/core` must stay Cloudflare-free Enforced by `biome.json`'s `packages/core/src/**` override (`noNodejsModules` plus `noRestrictedImports` on `cloudflare:*`, `@cloudflare/*`, `agents`, `drizzle-orm`, `hono` and `@signpost/db`) and by `"types": []` in `tsconfig.base.json`. `packages/core/tsconfig.json` sets `types: ["node"]` **only** for ambient Web Crypto and the standard globals both Node and workerd implement; importing any `node:*` builtin from `src/` is still forbidden. This is what makes the ≥95% coverage gate cheap and the pipeline testable in milliseconds. The hash is a hand-written synchronous SHA-256 (`markdown/sha256.ts`) precisely because `crypto.subtle.digest` is async and would force `parseNote` and `reconcileBlockIds` to become async to hash sixty-byte strings. ### 6.5 Never fork `@blocknote/*` — MPL-2.0 `AGENTS.md` states it as a convention. `scripts/licence-check.mjs` allows MPL-2.0 as a permissive licence, so the check will *not* catch a fork — review is the only thing that enforces this rule. BlockNote is a dependency, never the document model: [`EDITOR.md`](./EDITOR.md) §1 carries the ruling and says how an edit reaches the Markdown. ### 6.6 Queue names are account-scoped, not Worker-scoped `wrangler.jsonc` says so explicitly, which is why every queue carries the `signpost-` prefix: `kmem-index`, `kmem-git-sync`, `kmem-ingest`, plus `-dlq` for each. Mirrored as constants in `services/index-queue.ts:113-119`. Two kmem instances in one Cloudflare account would share queues. ### 6.7 `ReadableDatabase` is deliberately `Pick<>`-narrowed and must not be widened See §4.1. `select` and `selectDistinct`, nothing else — no `$client`, no `all`, no `batch`, no `prepare`. `ReadableBucket` is `get | head | list` — no `put`, `delete` or `createMultipartUpload`. Both are frozen null-prototype objects of bound methods, not proxies. Both failures that this repairs are recorded in the file header and both shipped. ### 6.8 The single-write-path scan has a hand-maintained allowlist `apps/worker/test/invariants/write-path-allowlist.ts` — sixteen exceptions in six files: `context.ts`, `auth/store.ts`, `tenancy/store.ts`, `storage/worklog.ts`, `services/spend/ledger.ts` and `routes/api/admin/audit-writer.ts`. Nine patterns are scanned, including `binding-alias` (`const store = ctx.bucket`, added after a rename made every other pattern blind) and `mint-claim` (**not allowlistable**). The test also enforces that every entry matches something, that every `file` exists, and that every `why` is a real sentence. Adding an entry is almost always the wrong fix. ### 6.9 `fileParallelism: false` in the worker vitest project is not a perf setting `apps/worker/vitest.config.ts`. `@cloudflare/vitest-pool-workers` isolates *storage* per test file and nothing else; the `RateLimiter` Durable Object, the edge Cache API and three module-level registries (`registerSessionResolver`, `registerBriefingComposer`, `reportMcpToolFootprint`) are shared and mutable. Run in parallel the project deadlocks at 0% CPU on roughly half of all runs of `test/routes/api`, and survives `--maxWorkers=4`. The config's own note says the thing to fix first is the shared mutable state, not the line. ### 6.10 Istanbul coverage, not v8 `vitest.config.ts` — `@vitest/coverage-v8` drives collection through `node:inspector`, which workerd does not implement; running it throws `ERR_METHOD_NOT_IMPLEMENTED` and silently drops the entire `worker` project from the report. Thresholds: 85% global on all four metrics, 95% on `packages/core/src/**` on all four. `packages/agent-kit` is excluded, because it is three hook scripts and the manifests that wire them into Claude Code. ### 6.11 Route groups are lazy `import()`s, and the reason is cold start `app.ts` and `index.ts` both argue it: cold start has a 100 ms budget and cold start is almost entirely module evaluation. A static import of the MCP SDK, `better-auth` or Drizzle at the top of `index.ts` would put all three in the startup path of an isolate that then serves one static asset. The app promise is memoised per isolate and a failed build **clears** the memo rather than caching the rejection. The one documented exception is `mcp/handler.ts`, which must sit at module scope so `notify` works — and `mcp/router.ts` uses top-level `await` for the footprint report. ### 6.12 `noStore()` on `/s/*` is a security control, not tidiness `app.ts` mounts it on the group. The Quick Connect URL path segment *is* the credential, so a response with no freshness information lets RFC 9111 §4.2.2 heuristic freshness apply — a proxy or a model provider's fetcher could store a note body keyed on the credential URL and replay it after revocation, without the guard that enforces I6 ever running. The 60 s briefing cache lives inside the Worker (`routes/quick-connect/cache.ts`) and needs nothing on the wire. ### 6.13 Two file headers point at names that are not there - `apps/worker/src/auth/schema.ts` refers to `auth/database.ts` twice. That file does not exist; the auth handle is built by `authDatabase` in `auth/store.ts`. - `docs/ADR/` has no `0007`. The numbers run 0001 to 0016 with that one gap, so a missing ADR 0007 is not a file somebody forgot to commit. Neither costs anything until you search for the name and conclude the code is missing. Read the directory before you conclude that. --- ## 7. `apps/worker` — file-by-file ### 7.1 Composition | File | Lines | What | | --- | --- | --- | | `src/index.ts` | 359 | entrypoint. Exports the four Durable Objects `NoteDoc`, `IngestAgent`, `GitAgent` and `RateLimiter`, the `ROUTE_GROUPS` loader table, and the `fetch`, `queue` and `scheduled` handlers | | `src/app.ts` | 217 | `createApp(options)`. Middleware order and the argument for it. Mounts `/health` first, web app last | | `src/context.ts` | 584 | `ProjectContext`, the read-only facades, `writableBindings` | | `src/http/types.ts` | 199 | `AppEnv`, `AppVariables`, `SignpostRouter`, `RouteGroupLoader`, `RouteGroups`, `MOUNT_POINTS` | Middleware order (`app.ts:104-138`): request id + logging (outermost, so the id exists before anything can fail and the duration covers everything), security headers (applied after `next()`, so they land on `onError` responses too), then per-group `errorDialect`/`noStore`/`projectContext`, then routes. Hono's `compose` catches at the frame that threw and lets outer middleware resume, which is what makes this ordering work rather than merely look tidy. `MOUNT_POINTS`, and `ROUTE_GROUPS` in the same order: `health: "/health"`, `api: "/api"`, `mcp: "/mcp"`, `quickConnect: "/s"`, `docs: "/docs"`, `ws: "/ws"`, `hooks: "/hooks"`, `try: "/try"`, `public: "/public"`, `sitemap: "/sitemap.xml"`, `web: "/"`. **`web` is the catch-all and must stay last**: every group above it falls through to the single-page app with `next()`, and `next()` only reaches the app if the app is registered after them. ### 7.2 `src/http/` — the cross-cutting layer | File | Lines | Key exports | | --- | --- | --- | | `bearer.ts` | 887 | `MCP_TOOL_SCOPES`, `QUICK_CONNECT_ENDPOINTS`, `authenticateBearer`, `authenticateQuickConnect`, `parseQuickConnectPath`, `recordSessionUse`, `QUICK_CONNECT_PREFIX` | | `errors.ts` | 613 | `RequestError`, `FailureKind`, `protocolError`/`applicationError`/`retryableError`/`internalError`/`unauthorized`/`forbidden`/`rateLimited`, `toRequestError`, `statusFor`, `renderError`, `errorHandler`, `notFoundHandler` | | `rate-limit.ts` | 593 | `MAX_REQUEST_BODY_BYTES` (= `MAX_MARKDOWN_LENGTH × 4`), `MAX_SMALL_REQUEST_BODY_BYTES` (64 KiB), `MAX_QUERY_LENGTH` (2,000), `REQUEST_LIMITS`, `readLimitedBody`, `readValidatedJson`, the six rate-limit headers, `rateLimitedResponse`, `uncacheable` | | `logging.ts` | 495 | `RequestLogger`, `LogRecord`, `consoleSink`, `collectingSink`, `REQUEST_ID_HEADER`, `newRequestId`, `requestLogging`. Redacts by construction | | `security.ts` | 597 | `CONTENT_SECURITY_POLICY`, `SECURITY_HEADERS`, `DELIBERATELY_ABSENT_HEADERS`, `securityHeaders`, `healthCors`, `errorDialect`, `noStore` | | `capabilities.ts` | 158 | the self-report registry: `MCP_TOOL_TOKEN_BUDGET = 3500`, `reportMcpToolFootprint`, `mcpToolFootprint`, `reportEmbeddingPipeline`, `embeddingPipeline`, `forgetCapabilities` | | `static.ts` | 364 | `AssetFetcher` (`Pick`), `RESERVED_PREFIXES`, `createStaticRouter`, `webRouteGroup` — the catch-all that serves the built single-page app | | `project.ts` | 55 | the `projectContext()` middleware | | `response.ts` | 79 | `setResponseHeaders` — handles immutable headers on subrequest responses | | `version.ts` | 26 | `BUILD_VERSION = "0.0.0"` | Error dialects: `/api` uses `"http"` (a 404 for a missing note, which is what a fetch wrapper expects); the agent doors use the default `"agent"` — 4xx for a protocol error, 200 with `{error}` for an application error, 503 for a retryable one. ### 7.3 `src/storage/` — repositories. Every mutating function takes `(ctx, token, …)` | File | Lines | What | | --- | --- | --- | | `write-token.ts` | 355 | the mint. §4.1 | | `blobs.ts` | 1,811 | R2. `NOTES_PREFIX`/`BLOBS_PREFIX`/`ASSETS_PREFIX`/`EXPORT_PREFIX`, `validateObjectKey`, `canonicalKey`, `canonicalKeyForPermalink`, `blobKey`, `sha256`, `putBlob`, `getBlob`, `blobExists`, `listVersions`, `putCanonical`, `getCanonical`, `canonicalSha`, `deleteCanonical`, `StorageError` | | `notes.ts` | 785 | `newNoteId`, `normalizeStoredPermalink` (idempotent — see the header for the `a/b.md.md` defect), `folderOf`, `getNoteByPermalink`, `listNotesByFolder`, `upsertNote`, `softDeleteNote`, `restoreNote` | | `blocks.ts` | 455 | `D1_MAX_BOUND_PARAMETERS = 100`, `maxRowsPerStatement`, `chunkForBoundParameters`, `assertWithinBoundParameterLimit`, `toBlockRow`, `listBlocksByNote`, `replaceBlocksForNote`, `runBatch` | | `chunks.ts` | 518 | `toChunkRow`, `listChunksByNote`, `listUnembeddedChunks`, `chunkIndexHealth`, `replaceChunksForNote`, `markChunksEmbedded`, `listChunksToReindex` | | `relations.ts` | 567 | `relationId`, `resolveTargets`, `listRelationsFrom/To`, `listUnresolvedRelations`, `upsertRelations`, `replaceRelationsForNote`, `resolveForwardReferences`, `unresolveRelationsTo` | | `provenance.ts` | 905 | `toProvenance(Row)`, `provenanceForBlocks`, `recordProvenance`, `setVerificationState`, `stampVerification`, `deleteProvenanceForBlocks` | | `audit.ts` | 1,486 | `REDACTED`, `redactSecrets`, `redactIdentifier`, `redactByFieldName`, `IDENTIFIER_FIELDS`, `containsSecret`, `prepareAuditRow`, `appendAudit`, `listAudit`, `lastAuditForTarget`, `toAuditEntry`, `auditIntegrity`, `auditDigest`. The header argues at length why there is no hash chain (ADR 0010) and why redaction is by field name rather than by pattern | | `tokens.ts` | 334 | session-link rows: `getSessionTokenByHash`, `insertSessionToken`, `markSessionTokenRevoked/Used`, the three audit action names | | `proposals.ts` | 573 | `listProposals`, `getProposalById`, `markProposalDecided` | | `tombstone.ts` | 654 | `Tombstone` class + `isTombstone`/`assertTombstone` (same nominal-brand pattern as `WriteToken`), `authorizeDeletion`, `applyTombstone`, `planReconciliationDeletions` — invariant I2 | ### 7.4 `src/services/` — the load-bearing ones | File | Lines | What | | --- | --- | --- | | `commit.ts` | 6,929 | §4.2 | | `briefing.ts` | 3,064 | §4.6 + `BRIEFING_TOOLS`, `renderBriefing`, `buildBriefing`, `renderToolsDocument`, `buildToolsDocument` | | `index-queue.ts` | 1,621 | queue names, `INDEX_JOB_VERSION = 1`, `indexJobSchema`, `enqueueIndexJob`, `planIndexWork`, **`embedChunks` (the seam)**, `accountForChunks`, `handleQueueBatch`, `handleIndexBatch`, `retryDelaySeconds` (backoff `[2, 8, 30]`) | | `tokens.ts` | 875 | session-link policy: `TOKEN_PREFIX "sp_"`, 32 base62 chars, `TOKEN_PATTERN`, `hashToken`, `constantTimeEqual`, `validateToken`, `mintToken`, `revokeToken`, `touchToken`, `expiryMilliseconds`, scopes | | `rate-limit.ts` | 650 | `REQUESTS_PER_MINUTE 60`, `REQUESTS_PER_HOUR 1000`, `MAX_CONCURRENT_EXPENSIVE 4`, `EXPENSIVE_LEASE_MS 30000`, `LIMITER_TIMEOUT_MS 1000`, the pure `takeRequest`/`releaseLease`/`windowAt`, and the `RateLimiter` Durable Object | | `agents-md.ts` | 629 | §4.6 projection | | `fts.ts` | 288 | `FTS_TABLE`, `ftsPayloadForParsedNote/StoredNote`, `indexChunksStatement`, `unindexChunksStatement`, `searchChunks` | | `compose.ts` | 181 | `composeNote` — the bytes a `remember` writes, **in exactly one place**. Header records the three ways the two doors had drifted, including Quick Connect heading its edge list `## Links` instead of `## Relations`, which made `relation-chunks.ts` fail to recognise it and packed the link list into ContextPacks as a claim | The rest of the directory holds one service per subject: `embedding.ts`, `reindex.ts`, `reenqueue.ts`, `attribution.ts`, `permissions.ts`, `proposals.ts`, `sessions.ts`, `trust.ts`, `usage.ts`, `ingest.ts`, `notes/`, `media/`, `export/`, `import/`, `git/`, `assistant/`, `spend/` and `tiers/`. **The `kmem-git-sync` queue has no consumer, and that is a decision.** Git sync and folder ingest are serialised by `GitAgent` and `IngestAgent`, because a queue cannot guarantee that two syncs of one repository never overlap. `handleQueueBatch` says so in a `warn` line where an operator will find it, and retries anything else that arrives rather than acknowledging it away. `kmem-ingest` carries one thing: archive import slices, consumed one message per batch by `handleImportQueueBatch` (`services/import/jobs.ts`). ### 7.5 `src/mcp/` — 7 tools, 1 resource, 2 prompts | File | Lines | What | | --- | --- | --- | | `router.ts` | 442 | the seven-step request chain: origin → body once → envelope → bearer (I6) → rate limit → bind + audit → handler. `readEnvelope` prefers the first **write** message in a batch, because reading the first message made a `[tools/list, tools/call remember]` batch record `mcp tools/list` in the audit row | | `server.ts` | 275 | `createSignpostServer` — one `McpServer` per request, data-driven registration, exhaustive `invoke` switch | | `handler.ts` | 264 | `createMcpHandler` at module scope + `Host`/`Origin` refusal | | `tools.ts` | 542 | `TOOL_SPECS` and the seven Zod input schemas | | `operations.ts` | 2,245 | the implementations: `recall`, `read`, `browse`, `remember`, `edit`, `verify`, `link`, plus `occupiedAddress`, `resolveRef`, `sectionSource`. The slug rule is `@signpost/core`'s and is imported, never restated | | `call.ts` | 125 | `McpCall` + `bindCall`/`callFor`, a `WeakMap` keyed on the `Request`. The raw token is deliberately absent | | `brief.ts` | 162 | `signpost://brief`, `BRIEF_URI`, `BRIEF_PERMALINK` | | `prompts.ts` | 143 | `capture-decision`, `session-summary` | | `results.ts` | 92 | `ToolResult`, `noCallContext()` — a tool with no bound call **refuses in words**, because a throw inside a tool handler becomes a protocol error the model never sees | | `footprint.ts` | 88 | `toolDefinitions()`, `TOOL_TOKEN_BUDGET`, `measureToolFootprint()` | | `schema-bridge.ts` | 94 | the one place a Zod schema is handed to the SDK | Tool → scope (`MCP_TOOL_SCOPES` in `http/bearer.ts`): `recall`, `read`, `browse` = `read`; `remember`, `edit`, `verify`, `link` = `suggest`. Expensive, so each takes a concurrency slot: `recall`, `remember`, `edit`. ### 7.6 `src/routes/` `health.ts` (494 lines) — `/health`, mounted always, resolves its own `ProjectContext` so it still answers when the instance is misconfigured. `HealthStatus`, `HealthReport`, `readSchemaVersion`, `INDEX_LAG_BUDGET_SECONDS = 5`, `assessHealth`, `buildHealthReport`. `routes/api/` — the REST surface, one file per subject plus two directories. The spine: `index.ts` (`API_GROUPS`), `common.ts` (the shared Zod schemas, including `waymarkSchema`, `blockSchema`, `noteSummarySchema`, `pageInfo`, `requireNote`, `normalizePermalinkOrRefuse`), `auth.ts` (`requireSession`, the session-resolver registry), `reference.ts` (the `API_ENDPOINTS` registry) and `docs.ts` (which renders it). The notes surface: `notes.ts`, `blocks.ts` (four ops on one endpoint), `backlinks.ts`, `history.ts`, `diff.ts`, `tree.ts`, `search.ts`, `recall.ts`, `media.ts`, `export.ts`, `import.ts`, `ingest.ts`, `git.ts`, `waymark.ts`, `verify.ts`, `trust.ts`, `worklog.ts`, `assistant.ts`, `webmcp.ts`. The account surface: `orgs.ts`, `front-door.ts`, `sessions.ts`, `session-link.ts`, `tokens.ts`, `usage.ts`, `spend.ts`, `admin/`, `billing/`. The operator surface: `health.ts`, `reindex.ts`, `proposals.ts`, `audit.ts` — the audit view, its `integrity` block and the `?digest=true` witness. `routes/quick-connect/` — `index.ts` (the handler table), `guard.ts` (the security spine — one wrapper every endpoint goes through, `mountEndpoint` the only registrar), `reads.ts`, `writes.ts`, `batch.ts`, `negotiate.ts`, `trail.ts`, `documents.ts` (`AGENTS.md`, `tools.json`, plus the `registerBriefingComposer` seam), `join.ts` (`join.sh`, the one document written for a person), `cache.ts` (the 60 s edge cache that cannot keep a revoked link alive — ADR 0008), `input.ts` (the validation order). ### 7.7 `src/auth/` — better-auth 1.6.26 `config.ts` builds the better-auth instance **per request** (two things it needs are per-request); `schema.ts` the six Drizzle tables + `AUTH_TABLES` (excludes `invite`) and `authSchema` (includes it); `store.ts` `authDatabase(env)` and every invite query; `routes.ts` `/api/auth/*`; `session.ts` `resolveSession`, registered at module scope; `invites.ts` the invite gate — **a `user` row is created only when a live invite bound to that exact address exists**, enforced in `databaseHooks.user.create.before`; `csrf.ts` `sameOriginRequired`; `mailer.ts` the Cloudflare Email Sending binding + `unverifiedSenderReport`; `page.ts` the sign-in HTML pages; `brand.ts` brand values copied from `brand/tokens.json` and asserted against it by `test/auth/brand.test.ts`; `settings.ts` `authConfigProblem`, `resolveBaseUrl`, TTLs, `MINIMUM_SECRET_LENGTH = 32`; `tenant-base-url.ts` the organization's own origin; `bootstrap.ts` the owner claim SQL, duplicated in `scripts/signpost-bootstrap.mjs` and asserted identical by a test; `env.ts` `AuthEnv` — deliberately not `Pick` because `wrangler types` generates vars as string *literal* types; `messages.ts`. Auth writes are the only writes in the Worker that do not go through `CommitService`, and they are the three allowlisted exceptions in §6.8. The gap this leaves — an auth event is not in `audit_log` — is stated in the file header. It closes with one function in `commit.ts`, never with a second mint. --- ## 8. `eval/` — the retrieval eval Not a workspace package. `runner.mjs` imports `packages/core/src` **as source** through `loader.mjs`, so a scoring change is measurable without a `tsc`. | File | Lines | What | | --- | --- | --- | | `runner.mjs` | 2,157 | the CLI, the safety probes, the gate | | `validate-questions.mjs` | 1,037 | resolves every expectation through the real parser and chunker | | `metrics.mjs` (+ `.d.mts`) | 1,012 | every metric as pure functions over arrays | | `report.mjs` | 678 | JSON + Markdown rendering | | `retriever.mjs`, `worker-retriever.mjs` | 556 + 950 | the two backends: `reference` (in-process) and `worker` (HTTP against a deployment) | | `loader.mjs` | 53 | imports `packages/core/src` | | `split.mjs`, `split.json` | 445 | the dev and held-out split, committed rather than passed as a flag | | `corpus/` | — | 100 generated notes across `architecture`, `conventions`, `decisions`, `people`, `runbooks`, `state`; `corpus.json`, `conflicts.json`, `gaps.json` | | `questions/*.json` | — | the question set, in eight files by subject | | `results/` | — | the last run, committed, so a tuning change is compared against a number rather than a memory | The corpus is generated by `scripts/seed-corpus.mjs` and is **never hand-edited** — dates are computed from a moving anchor so freshness decay stays exercised. `pnpm seed-corpus:check` re-derives from the anchor recorded in `corpus.json` and is a CI step. [`eval/README.md`](../eval/README.md) is the authority on the gate: it lists every check, both thresholds, and the reason `precision@5 >= 0.80` was retired and must not be restored. Two rules from it are worth repeating here. **Always pass `--gate`**, because without it the runner prints `FAIL` and exits `0`. And **every threshold is a constant in `runner.mjs` with no flag to lower it**: a threshold the command line can lower is a threshold somebody lowers the first time a release is late. If a number is short, report it. Do not move the line. --- ## 9. `e2e/`, `brand/`, `design/`, `scripts/` **`e2e/`** — Playwright. `tests/` holds the smoke spec plus the craft and landing specs, `probes.ts` the shared checks and `prove.mjs` the runner that drives them against a running instance. `playwright.config.ts` has **no `webServer`**: the specs measure a deployment, not a dev server. **`brand/`** — `tokens.json` is the machine-readable output and is read by `apps/worker/test/auth/brand.test.ts` to assert the copied values in `src/auth/brand.ts`. `fonts/` holds the shipped faces and `fonts-src/` the woff2 build. Excluded from Biome. **`design/`** — `CONTRACT.md` is what a screen must satisfy, `DESIGN.md` the visual system, `screens/` one specification per surface, `copy.md` the strings, `tokens.css` the values the app imports, plus `mark.svg` and `wordmark.svg`. Where a screen and its specification disagree, the specification is the one to change first. **`scripts/`** — `licence-check.mjs` carries three rules: copyleft in production is fatal with no exception mechanism; copyleft dev-only is fatal unless named in `DEV_TOOLCHAIN_EXCEPTIONS` with a reason; an unrecognised licence is fatal. `seed-corpus.mjs` generates the eval corpus. `signpost-bootstrap.mjs` mints the owner claim by writing only a SHA-256 into `invite` through `wrangler d1 execute`, and prints the URL once; the SQL is duplicated from `auth/bootstrap.ts` and a test asserts the two are identical. `provision-tenant.mjs` creates an organization, `ensure-vector-metadata-indexes.mjs` provisions the Vectorize metadata indexes `pnpm release` depends on, `index-backfill.mjs` embeds what fell behind, `mint-link.mjs` issues a session link, and `check-doc-links.mjs` plus `check-doc-manifest.mjs` hold `docs/` together. `apps/worker/scripts/` — `tool-footprint.mjs` (publishes the number into `README.md`; `--check` is a CI step), `generate-api-docs.mjs` (writes `docs/API.md`), and two Node loader hooks that let both run against TypeScript source with no build: `ts-resolve.mjs` (rewrites `./x.js` → `./x.ts` when the `.js` does not exist) and `workerd-stub.mjs` (answers `cloudflare:*` with placeholder classes). --- ## 10. Build, test and CI ```sh pnpm dev # wrangler dev pnpm build # tsc --build && vite build (apps/web) pnpm typecheck # tsc --build --force, every project incl. tests and configs pnpm lint / pnpm format # biome check [--write] pnpm test # every vitest project pnpm test:core # packages/core, in Node pnpm test:node # every project except worker, in Node pnpm test:worker # apps/worker in workerd, serial pnpm coverage # istanbul, 85% global / 95% packages/core, all four metrics pnpm coverage:merge # merge sharded blob reports, then the same coverage gate pnpm licence-check pnpm docs:links / pnpm docs:manifest pnpm mcp:footprint[:check] pnpm seed-corpus[:check] pnpm db:generate / db:migrate:local / db:migrate:remote pnpm vectorize:metadata # provision the Vectorize metadata indexes pnpm worker:types # regenerate src/worker-configuration.d.ts from wrangler.jsonc pnpm e2e pnpm release # db:migrate:remote && vectorize:metadata && wrangler deploy node eval/runner.mjs --gate [--output=eval/results/] node eval/validate-questions.mjs node apps/worker/scripts/generate-api-docs.mjs node scripts/signpost-bootstrap.mjs --email you@example.com [--local] ``` **The deploy script is `release`, never `deploy`.** `deploy` is a pnpm builtin, so `pnpm deploy` answers `ERR_PNPM_NOTHING_TO_DEPLOY` and never reaches this repository at all — and `release` is what provisions the Vectorize metadata indexes, without which a dense query filters against nothing and retrieval silently returns zero results. CI (`.github/workflows/ci.yml`) is several jobs, because one could not finish inside any sane timeout. `static`: lint → typecheck → licence-check → `docs:links` → `docs:manifest` → `mcp:footprint:check` → `seed-corpus:check` → build → migrations applied twice against an empty `.wrangler/state`. `node-tests`: `pnpm test:node`, every project except `worker`. `worker-tests`: the worker project split across six runners with `vitest --shard`, because it runs one file at a time and takes about half an hour serially. Each shard runs `pnpm build` first, because the `ASSETS` binding serves `apps/web/dist` and the suite asserts on what it serves. **On `main` and manual dispatch only**, `coverage-shards` runs `pnpm coverage` in six shards that each upload a blob report with their own thresholds off, and `coverage` merges them with `pnpm coverage:merge` and enforces the 85% / 95% gate on the merged map. `WRANGLER_SEND_METRICS: "false"`; everything runs against local D1, R2 and Durable Objects inside workerd with no Cloudflare account. `eval` needs `static`, `node-tests` and `worker-tests`, runs on `main` and manual dispatch only — retrieval quality is a property of the merged tree — and runs `pnpm eval:questions` then `pnpm eval`. Node 24.15.0, pnpm 10.20.0. Key pins: TypeScript 7.0.2, vitest 4.1.10, `@cloudflare/vitest-pool-workers` 0.20.3, wrangler 4.120.0, biome 2.5.7, hono 4.13.1, zod 4.4.3, drizzle-orm 0.45.2, better-auth 1.6.26, `@modelcontextprotocol/server` 2.0.0, agents 0.20.1, react 19.2.8, vite 8.2.1, playwright 1.62.1. `wrangler.jsonc`: `compatibility_date: "2026-08-01"`, `compatibility_flags: ["nodejs_compat"]`, observability on at `head_sampling_rate: 1`, and the routes that serve the apex and every organization subdomain of `SIGNPOST_ROOT_DOMAIN`. Bindings: `DB` (D1 `signpost`, with `migrations_dir: packages/db/migrations`), `BUCKET` (R2 `kmem-notes`), `VECTORS` (Vectorize `kmem-chunks`), `AI`, `ASSETS` (the built single-page app from `apps/web/dist`), four Durable Object bindings (`NOTE_DOC`, `INGEST_AGENT`, `GIT_AGENT`, `RATE_LIMITER`), three queue producers and three consumers with dead-letter queues, a `0 3 * * *` cron, and the `send_email` binding `EMAIL`. Vars: `SIGNPOST_PROJECT_ID`, `SIGNPOST_PROJECT_NAME`, `SIGNPOST_WRITE_POLICY`, `SIGNPOST_BASE_URL`, `SIGNPOST_ROOT_DOMAIN`, `SIGNPOST_EMAIL_FROM`, `SIGNPOST_PUBLIC_DOCS`, `SIGNPOST_PUBLIC_DOCS_ORG`, `SIGNPOST_SUPERADMIN_EMAIL`. `SIGNPOST_AUTH_SECRET` is a secret, so it is not in the file: `npx wrangler secret put` sets it.