Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Future peer: iOS app (Swift).
<!-- GENERATED by `coherence claude` from the spec+code graph. Do not edit by hand —
edit the *.spec.md files and re-run. Everything OUTSIDE these markers is authored prose. -->

_Derived from 8 components · 68 files · 485 symbols · 16 boundary claims._
_Derived from 8 components · 68 files · 487 symbols · 16 boundary claims._

## Component map (derived)

Expand Down Expand Up @@ -161,7 +161,7 @@ _files:_
| served-content inertness | Routing | `inertHeaders` | `served-content inertness totality` |
| served-read gating | Routing | `denyUnlessBearerScope` | `served bearer-gating totality` |

<sub>Generated at 2026-08-03 15:26Z.</sub>
<sub>Generated at 2026-08-14 15:04Z.</sub>
<!-- coherence:end -->

## Current state
Expand Down
8 changes: 4 additions & 4 deletions mnemion-js/docs/coherence/_graph.html

Large diffs are not rendered by default.

215 changes: 121 additions & 94 deletions mnemion-js/docs/coherence/graph.json

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions mnemion-js/entities/Hive/Hive.spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ The single per-user Durable Object that owns all SQLite data and funnels every a
- data.ts imports ./kernel-columns
- evolution.ts imports ./kernel-columns
- schema.ts imports ./kernel-columns
- schema.ts imports ../../shared/core/sql
- hive.ts imports ./kernel-columns
- data.ts exists at this node
- mutate-gate.ts exists at this node
Expand Down Expand Up @@ -79,6 +80,10 @@ HiveDO is the single Durable Object that owns the SQLite store; every write funn

**token-scope-grammar.** The classifier dual of credential-mint gating: that boundary decides a credential-minting create must be consent-gated; this decides WHICH scopes are "broad" enough to require the round-trip — so the partition `isBroadTokenScope` draws over the scope grammar IS the boundary. It drifted: a bare `parts.length >= 3` cutoff classified `read:entry:<pattern>` (3 parts) as narrow, but `scopeMatches` prefix-grants it every `read:entry:<pattern>:<id>` — a pattern-WIDE standing read key minted with NO round-trip, an exfil credential. The fix classifies by RESOURCE GRAMMAR, not length: a scope is narrow only when it reaches its kind's LEAF depth (`entry` at depth 4 — `pattern:id`; `output`/`publication`/`document`/`input` at depth 3), so `read:entry:<pattern>` falls short and is broad. The oracle reconciles the partition against both `scopeMatches` and the kinds `io.ts` actually mints, so a new served resource kind can't leave it incomplete.

**boot quiescence.** `initializeSchema` runs in the HiveDO CONSTRUCTOR, so it executes on every cold start / wake from hibernation — a rate set by client reconnects, not by the owner. Two of its steps are reconciliation (register the kernel patterns in `_objects`; drop-and-recreate every audit trigger so the captured column list tracks the schema) and both ran unconditionally, writing ~185 rows per wake on a hive nobody had touched. That consumed 90% of the 100k/day Durable Objects `rows_written` free-tier allowance on a day with six agent writes — a cost with no relationship to use. The rejected fix was making the trigger rebuild conditional again per-site (`CREATE TRIGGER IF NOT EXISTS`), which is what the always-rebuild behavior deliberately replaced: a trigger built before a column existed silently omits it from `_mutation_log` forever. Instead the reconciliation is gated on a `bootFingerprint` stamped in `_meta` — derived FROM the declarations plus the live column signature, never hand-maintained, so a forgotten input can only cost a redundant rebuild, never a stale one; any read that throws, a missing stamp, and any difference all resolve to "reconcile" (fail closed). It is stored whole rather than hashed precisely because a collision would resurrect the stale-trigger bug, and exact comparison costs one row write per actual schema change. Live schema changes never wait on a boot — `evolution.ts` calls `ensureAuditTriggers` directly. The oracle is behavioural rather than structural: `boot-idempotence.test.ts` counts ACTUAL `rowsWritten` through a proxied `SqlStorage` and asserts a second consecutive boot writes zero, so it fails on any newly added unconditional write regardless of which line introduces it. One settling boot is allowed — reconciliation and the GC sweeps exist to do work; the pathology was paying that cost every time, forever. Not modelled as a trust-crossing `boundary`: nothing here is a security property, and over-enshrining is its own pathology.

**boot quiescence — the read side.** Writes were only the billed symptom. The same per-wake reconciliation also READ ~9.7k rows per boot (`DROP TRIGGER` scans `sqlite_schema` — 116 rows a statement, six per pattern), and Cloudflare's hourly figures show reads tracking writes at a constant ~52.5:1 across a 50× range of activity, which is what identified both as the same cold-start population rather than request traffic. Gating the rebuild fixes both. The separate offender was `applyTokenHashScrub` (the pre-born-hashing cleanup): "idempotent, a near-no-op after the first cold start" was true of its WRITES and false of its READS — six full scans of `_mutation_log`, 6,000 rows, on every wake forever, to re-discover there was nothing to do. That is the general trap: a migration whose *am-I-done?* question is a table scan cannot answer it per boot. `runOnce` gives such work a completion stamp (`_completed_migrations`), stamping only on success so a part-way failure retries. It is deliberately NOT the home for migrations that must keep reconciling against the code — those belong behind the fingerprint. The scrub moved from `hive.ts` into this module so the whole boot sequence is `initializeSchema` + `applyTokenHashScrub` and the oracle can measure BOTH halves; a test covering only the first would have passed while the scrub burned the read budget. The read oracle asserts a property rather than a threshold — a quiescent boot's read count must be IDENTICAL at two data volumes — so it needs no magic constant and catches any future boot-time scan. Verified by negative control rather than assumed: removing the `runOnce` gate made it fail by name — "boot reads grew with data volume (619 → 8219)" — then restored to green. (Recorded here rather than under `## refutations`, which keys entries to a declared invariant; boot quiescence deliberately is not one.)

**kernel write boundary — two transports.** The kernel write boundary reaches the engine over TWO transports: the interactive MCP `mutate` tool and the browser-authenticated `/api/mutate`. The rejected design had gating decisions inlined in the MCP handler; the drift vector was real — an `/api`+RPC test passing while the MCP Zod/consent layer silently broke, so the boundary's enforcement disagreed across the two paths a write can take. Pure decisions in one tested leaf (shared by both transports) is the fix. (Interactive consent round-trip mechanics stay in the session handler because only MCP can satisfy them; `/api` is owner-implicit — a logged-in human IS the consent.)

**kernel read+write capability.** Reads share the SAME boundary on the SAME flag: `DataContext.trusted` is required and gates kernel access symmetrically — an untrusted context may neither write nor read a kernel pattern. Trust is a CAPABILITY, not a per-call-site convention: `HiveDO` exposes two named constructors over a trust-agnostic `ctxFields` — `ownerDataCtx` (the ONLY `trusted: true`) and `servedDataCtx` (`trusted: false`) — with no trust parameter to dial at a call site, so served reads (public page, OG card, publication, `/o/entry`) AND untrusted writes (ingress, upload) physically cannot reach kernel data. All served public reads live in one module (`served.ts`), handed a narrow `ServedContext` exposing only `servedQuery` for user-pattern data plus single-answer kernel-CONFIG lookups (a `_shared` visibility, an endpoint config row, supersession ids, facet metadata) — never `db`, never a trusted context, no way to construct one. This made the old per-block / per-entry `isKernelPattern` guards provably redundant (a kernel-named read returns empty through `servedQuery`, which also binds the id against injection); they were deleted, leaving one chokepoint instead of a guard to forget per sink — replacing a block-list that failed open.
Expand Down
36 changes: 10 additions & 26 deletions mnemion-js/entities/Hive/hive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
import { DurableObject } from "cloudflare:workers";
import { URI_SCHEME, URI_PREFIX, uri, OWNER_ACTOR, IDENTIFIER_RE } from "../../shared/core/constants";
import { evaluateMapping } from "./transform";
import { initializeSchema } from "./schema";
import { initializeSchema, applyTokenHashScrub } from "./schema";
import { KERNEL_COLUMN_SET, STRUCTURAL_KERNEL_COLUMNS } from "./kernel-columns";
import { expandShortcut, normalizeHost } from "./kernel";
import { isKernelPattern, isValidWriteTarget, writeClass, seal, sealAll, secretColumn, SENSITIVE_COLUMNS, primeIncluded } from "./policy";
Expand Down Expand Up @@ -58,32 +58,16 @@ export class HiveDO extends DurableObject {
/** One-time cleanup of secrets that leaked BEFORE born-hashing: hash any legacy
* plaintext token still in `_access_tokens` (raw = 32 hex, digest = 64), and
* scrub any raw token the old post-insert path left in the `_mutation_log`
* audit trail. Idempotent; a near-no-op after the first cold start. */
* audit trail.
*
* Runs at most once per hive (`runOnce`). It was "idempotent, a near-no-op
* after the first cold start" — but the no-op still COST six full scans of
* `_mutation_log` (6,000 rows read) on every wake to re-discover there was
* nothing to do. Purely historical work: no new sensitive value can enter the
* audit log, because the triggers record `SENSITIVE_COLUMNS` as NULL and
* secrets are born hashed, so once converged it can never un-converge. */
private async migrateTokenHashes(): Promise<void> {
try {
const rows = this.db.exec(`SELECT id, token FROM "_access_tokens" WHERE length(token) != 64`).toArray() as any[];
for (const r of rows) {
if (typeof r.token !== "string") continue;
try { this.db.exec(`UPDATE "_access_tokens" SET token = ? WHERE id = ?`, await cred.hashToken(r.token), r.id); }
catch { /* a bad row must not brick construction — skip it */ }
}
} catch { /* table may not exist yet on a brand-new hive */ }
// Audit-log scrub: NULL out every sensitive value the pre-recreate audit
// triggers captured (raw tokens, passkey material). Derived from SENSITIVE_COLUMNS
// so it covers every classified column; idempotent (once null, the guard skips it).
try {
for (const [table, cols] of Object.entries(SENSITIVE_COLUMNS)) {
for (const c of cols) {
for (const field of ["new_data", "old_data"]) {
this.db.exec(
`UPDATE _mutation_log SET ${field} = json_set(${field}, '$.${c.column}', null)
WHERE table_name = ? AND json_extract(${field}, '$.${c.column}') IS NOT NULL`,
table,
);
}
}
}
} catch { /* best effort */ }
await applyTokenHashScrub(this.db, cred.hashToken);
}

/** Born-hashed secrets: for a CREATE of a pattern with a `secret` column
Expand Down
Loading