knowledge-schema
Skill1 starsChanged 15 days agoMITView on GitHub
Changes Sphica's DB schema (db/schema.sql, SQLite), the record model (sources, units and their evidence, adoption, links, states, anchors, aliases), connection roles and authorizers, the full-text search index (FTS5), and how capture and the record server write. Use when touching tables, columns, CHECKs, views, triggers, permissions, search, or a new ingestion path.
In this repository7 other files it gives its agents.
- AGENTS.mdAGENTS.md
- CLAUDE.mdCLAUDE.md
- plugin-agent-authoringSkill
- plugin-releaseSkill
- codex-reviewSkill
- eval-loopSkill
- fork-prSkill
The file
We split this skill into the rules it gives an agent
7 rules. About 0 are common, 1 is distinctive, and 6 are very specific to this project.
≈0
Common · estimate
Many files like this have a rule of this kind.
≈1
Distinctive · estimate
This author's own choices. Worth a look.
6
Very specific
Tied to their project's names, files or tools. Probably skip.
- Common
- Distinctive
- Very specific
- Distinctive: line 37
Keep the capture views' columns unchanged across a revision, since capture writes across the change
- Very specific: line 57
Keep one path rule: source.path, edit_observation.path, and unit_anchor.path share one CHECK expression (bun run pairs compares them)
- Very specific: line 75
A question an answer closed keeps the record that answered: When a trace's answers withdraws a question, reconcile() writes a unit_answer …
- Very specific: line 147
Write connections live only in server/src/db-write.ts.
- Very specific: line 150
Before adding a function to READER_FUNCTIONS because a read fails with not authorized, see whether the code can compute it instead (the …
- Very specific: line 157
Write queries with kysely and let it infer types.
- Very specific: line 181
Always quote query terms (ftsQuery).
≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.
Files
Change the knowledge schema
Triggers
- Changing tables, columns, CHECKs, indexes, views, or triggers in
db/schema.sql - Changing connection roles (the authorizers in
server/src/sqlite.tsandserver/src/db-write.ts) - Changing the full-text search index (FTS5,
sphica_terms,terms()/queryTerms()inserver/src/text.ts,server/src/search.ts) - Changing the vocabulary in
server/src/knowledge.ts(unit kinds, stances, lifecycles, option outcomes, evidence roles, source kinds) - Adding an ingestion source, or changing how capture, trace, harvest, or glean write
Does not trigger
- Work that only creates a DB
- Changing only the text of a Skill or of MCP replies (
plugin-release)
Source of truth and versions
The DB is a single node:sqlite file (~/.sphica/sphica.db). The only source of truth is db/schema.sql. Do not add an ORM schema as a
second source (Drizzle was rejected: it cannot express FTS5 virtual tables and triggers). sphica init applies schema.sql to a temporary file and renames it.
Two numbers version it:
- Generation (
sphica_generation,SCHEMA_GENERATIONinserver/src/sqlite.ts): the record model. 0.5.0 is generation 2. A database of another generation is refused without being changed; the owner moves it aside and runssphica init(0.4 records are not carried over) - Revision (
pragma user_versionat the end of schema.sql,SCHEMA_REVISION): changes within a generation. Keep the two equal; the reader and ingest connections stop on a mismatch. Capture checks only the generation, so recording keeps working between a DB change and a plugin update
A revision change ships with a migration. db/migrations/<revision>.sql (4 digits, 0002.sql moves 1 → 2) holds the same statements as
schema.sql's new state; sphica init runs each in one transaction with foreign keys off and checks foreign_key_check before committing
(migrate() in server/src/admin.ts). Rebuilding a table that other triggers name needs those triggers dropped first, or the rename fails.
server/test/migrate.test.ts compares a migrated DB with a fresh one; keep the previous revision's schema in server/test/fixtures/.
Keep the capture views' columns unchanged across a revision, since capture writes across the change
Rows the new revision refuses are handled in three ways, and none is left to fail the rebuild:
- Repaired: take the nearest value the new rules accept, or the row goes when it is derived (a duplicate, an observation). Write each into the temp table
sphica_migration_note (rule, item, action);migrate()prints every row after the step commits. A lifecycle repair adds a state from a run with originmigration(targetrevision:<n>) and setsunit.lifecycleandrevisionby hand, since the triggers are dropped during the rebuild - Stopped: a row no release wrote, or a source the migration may not remove (only the owner's forget removes sources).
<revision>.check.sqlruns first in the same transaction and fillssphica_migration_stop (rule, item); any row stops the step with every row listed and nothing changed - Rebuilt tables lose their
sqlite_sequencerow: save the counters first and put back the larger of old and copied, before anything inserts into the table
When changing the schema
- Regenerate
server/src/db-types.tswithbun run codegen. Do not edit it by hand. CI'scodegen:checkfails on drift - Make every table
strict, and writenot nullon every primary key - Give time columns
check (strftime('%Y-%m-%dT%H:%M:%fZ', column) is column)(with=, an invalid string passes as NULL). Writers useiso()inserver/src/db.ts - Enforce cross-table consistency (same project, spans inside the source) with triggers, not only in code;
server/test/schema.test.tstries each refusal on a real DB - A value set lives in a CHECK and in
server/src/knowledge.ts;scripts/check-pairs.mjscompares them. Add a pair there when you add a set - Lead every foreign key with an index on its own columns (
schema.test.tschecks it). A row goes with what it belongs to or with a source it cites (cascade); a column naming where a row came from (run_id,forget_id,edit_observation_id) takes no action - Keep one path rule:
source.path,edit_observation.path, andunit_anchor.pathshare one CHECK expression (bun run pairscompares them)
The record model
Four boundaries (the header of schema.sql):
| Boundary | Tables | Rule |
| Captured sources | session, source, artifact_link, edit_observation | Never rewritten. A changed external item (an edited PR body) is a new revision |
| Units | unit and its unit_option, unit_evidence, unit_adoption, unit_link, unit_state, unit_answer, unit_anchor, unit_alias | Text never rewritten; corrections are successors (supersedes), retractions, and anchor replacements |
| Processing | extraction_run, source_processing | What each run looked at, so untraced sessions are counted, not guessed |
| Work and delivery | work, delivery, delivery_unit | Current work, and what the hooks showed (unit ids, never text) |
- State is judged from facts. Writes (saves, glean, forget, migration) change only facts: intents (
unit_linksupersedes, one per record), evidence, adoption, explicit withdrawals. Thenreconcile()inserver/src/reconcile.tsloads every record the change reaches through supersedes, runs the purejudge()inserver/src/judge.ts, and writes only the difference: ended replacement rows, opened ones, thenunit_staterows. It judges again and throws if anything is left over. It is the only writer ofunit_state,unit_replacement, andunit_answer(bun run architecturechecks it); triggers only check that what was written follows the rules - A question an answer closed keeps the record that answered. When a trace's
answerswithdraws a question,reconcile()writes aunit_answerrow (the withdrawal's state row, the answering record) with that state row, also for an older question closed through the one named. A withdrawal the owner asked for has none. A reader tells who answered from this row (server/src/timeline.ts), never from the reason's words; withdrawals from before revision 13 got their row from the migration, when one record of their save had the key - Lifecycle moves through
unit_statealong a fixed table: the first state is candidate; candidate → active, superseded, or withdrawn; active → candidate, superseded, or withdrawn; superseded → active or candidate; withdrawn is final. Its trigger setsunit.lifecycle - Support is one view,
unit_support: a decision or constraint needs unit-level unretracted evidence and adoption (anagentadoption counts only with its paireddecidesevidence); an implementation needs code or commit evidence (or anevidenceanchor on a path its session edited); a finding, dead end, or question needs unit-level evidence. Activating, retracting, and retiring an anchor all read it, so they never disagree. Quarantined and unsourced units never become active - Replacement:
unit_linksupersedes is the intent;unit_replacementholds when a replacement was in effect (start, end, and the run or forget that caused each; a row ends once and is never deleted). At most one open row per replaced record: its holder is the successor in effect. A successor holds the place while it is eligible (not withdrawn, supported, sourced, and for a decision or constraint with an intent, adopted by the owner or a maintainer); when the place is free, the earliest intent takes it. A record superseded before revision 10 whose history was not recorded carries a mark inunit_replacement_gap, so read does not show it as a proposal never carried out - Evidence is a byte span of retained text (
span_start,span_endinto the UTF-8 bytes ofsource.text). Quotes are located by the save path, never trusted - Adoption routes:
owner_statement(an owner-kind source),explicit(the owner, or OWNER / MEMBER / COLLABORATOR), oragent: the AI's own reply in a session, paired withdecidesevidence on the same span, saved by a run whose begin call was interactive, from a reply with a turn that ran no record tool (the viewagent_ineligible_source). Theunit_adoption_routetrigger enforces this;agentRefusalinrecord.tsalso checks AskUserQuestion questions, rule and CI file anchors, and a same-turn edit for adowithapplies_toanchors. A merge or a resolved thread never adopts.authorityOfinserver/src/authority.tssays whose decision a record is as of any time: the owner's, an AI's, or no one's - Aliases are search words bound to the unit's
content_hash; only the newest matching set is indexed. They are never evidence - Anchors hold a path, symbol, and the lines where the symbol was when saved. They are checked against the working tree when read (
server/src/anchors.ts), never cached - Runs bind writes.
extraction_run.draft_idis the run id the record server's begin tools issue; check and save take it, and the record never names a project or target - Record tool calls are logged. The record server logs every tool call in
record_callbefore it runs (host, caller session and turn, mode read bycallerOfinserver/src/caller.ts), and a run keeps its begin call. Claude Code puts no turn in a call, so a synchronous PreToolUse hook writestool_call_observationthrough the capture viewcapture_tool_call, joined by tool use id. The hook first keeps the same observation inspool/calls/and removes it once written; one it could not write (the lock held past its wait) is written by the next send, which lifts the stop on later replies but never on the calling turn's. A call with no hook row, no tool use id, or before install stays unjoined for good: nothing guesses its turn from sessions or times
Writers
| Writer | Connection | Path |
| Capture hooks | capture | server/src/capture.ts: capture_session, capture_message, capture_edit views |
| Delivery hooks | reader, then capture | server/src/deliver.ts: reads units, logs through the capture_delivery view |
| Record MCP server | ingest | server/src/mcp-record.ts → extract.ts → record.ts (units), glean.ts (changes), github.ts (sources); states and replacement rows through reconcile.ts |
| Record tool hook (Claude Code) | capture | observeRecordCall in server/src/capture.ts, through the capture_tool_call view; what it could not write, by the next send from spool/calls/ |
sphica init | owner, then ingest | server/src/admin.ts creates the DB; cli.ts registers the project |
sphica doctor --reindex | owner | reindex() in admin.ts |
sphica init on an older revision | owner | migrate() in admin.ts |
/sphica:forget | forget | the record server's forget_apply → forget.ts, only after the owner confirms |
A new ingestion source writes through the record server's run-bound tools or capture, never a bulk import.
Status edits and turn starts
Capture writes via: "status" edits from git status at a turn's start and end (openTurn and closeTurn in server/src/capture.ts).
Hooks of different turns can run out of order, so the starts follow these rules. Keep them when changing capture:
- Each turn's start is its own file under
~/.sphica/worktree/<session>/, numberedseq. No hook rewrites or deletes another turn's start - A Stop gives status edits to its turn only if, read again after its end snapshot, that turn is the single newest one and its own start is unchanged. A tie, a null
seq, or an unreadable file drops them: a lost edit is better than one given to the wrong turn - A Stop leaves its end snapshot, so another Stop with no prompt in between (a Stop hook kept the turn going) compares from there. A prompt that reuses the id takes a new snapshot first. An Interrupt, or a Stop whose start has no snapshot, ends the turn and keeps only the number
SessionStartwrites no start. Prune removes a session's directory only when every file in it is older thanHOLD_DAYS, never an empty one or single files- The owner's prompt is queued before the start is taken, and a failed start never stops the messages, the send, or the session notice
Gaps left open: a turn started by a notice can reuse an interrupted turn's id (nothing the hooks receive tells an interrupt apart); edits
made before the end snapshot runs; a start that could not be written; a hook still running past its timeout; a session resumed after
HOLD_DAYS while its old starts are being pruned.
Connection roles
Processes of the same OS user can rewrite the file directly, so this is not an OS boundary. It guards the path where Sphica's code writes by mistake.
| Role | Authorizer | Used by |
| owner | none | creating the DB, reindex, the database check in doctor |
| reader | reads and allowed functions (READER_FUNCTIONS) only | the read MCP server (mcp.ts), delivery reads, doctor's project list |
| ingest | writes only what the record server's code writes: listed tables, listed update columns, deletes of artifact_link, and inside triggers only what INGEST_TRIGGER_WRITES lists (a test compares it with every trigger body). Sources go through the view ingest_source, which cannot take a session message | the record MCP server, project registration |
| forget | inserts into forget_batch, source_forgotten, unit_state, unit_replacement (and ends rows); deletes sources and what cites them; secure_delete and wal_checkpoint pragmas. Ingest may do none of the forget-only writes | forget_apply in the record server |
| capture | inserts into the capture views only; reads only project's id, key, and name, session's id, and source's id, session, external id, and kind; functions only inside the views' triggers (TRIGGER_FUNCTIONS) | capture and delivery logging |
- Write connections live only in
server/src/db-write.ts;bun run architecturechecks the read MCP server cannot reach them - Enable
enableDefensive(true)on every connection (it stops direct writes to FTS5 shadow tables) - Initialization order is fixed: open → defensive and pragmas →
sphica_terms→ authorizer - Before adding a function to
READER_FUNCTIONSbecause a read fails withnot authorized, see whether the code can compute it instead (the read-delivery budget sumscharsin JS rather than widening the reader) - Do not count rows by affected rows through a view (an insert into a view reports 0). Count by what exists before and after
- Judge permissions by running them:
server/test/db.test.tstries each role's allowed and forbidden operations on real connections. A trigger function the authorizer denies fails only at run time (the delivery log once failed this way unnoticed)
How to write SQL
Write queries with kysely and let it infer types. openReader() returns kysely's ReadonlyKysely<DB>, and a function that only reads takes Reads
(in db.ts), which both the reader and a writer fit and which cannot write. Only sqlite.ts, db-write.ts, db.ts, admin.ts, and kysely-node-sqlite.ts may use node:sqlite
directly (bun run sql). Name a node:sqlite connection raw, and keep raw.prepare( on one line (the SQL ledger finds call sites by line).
| Shape | How to write it |
| JSON columns | ParseJSONResultsPlugin turns only the JSON_COLUMNS in db.ts back into values. On write, pass JSON.stringify output |
| Word search | Join FTS5 as a subquery in a sql template (search.ts). Build the query with ftsQuery |
| Times | ISO 8601 UTC strings to the millisecond. Lexical order is time order |
| Booleans | integer 0/1 |
| BLOBs | Come back as Buffer (the adapter converts); compare hashes with .equals |
| Transactions | inTransaction in db.ts (begin immediate). Do not run queries in parallel inside one |
Full-text search
unit_fts (columns body, ident, alias; filled from the view unit_search_text) and source_fts (owner words and third-party text; assistant replies are not indexed).
Both are contentless and filled by triggers calling sphica_terms, which is terms() in server/src/text.ts.
terms()splits with Intl.Segmenter, drops hiragana-only words and English stop words, keeps identifiers whole, keeps a kanji word's kanji (conjugations meet), and makes English plurals singular. The index and queries go through the same function- Changing
terms()leaves existing indexes old. Raise the revision and end its migration with the statementsreindex()inadmin.tsruns, sosphica initrebuilds both indexes.server/test/terms-golden.test.tspins the output for fixed inputs inserver/src/terms-golden.json; it cannot stop only its expected values being updated. The file ships, andsphica doctorchecks the running Node splits the same inputs the same way (an ICU difference) queryTerms()drops question framing (why, which, and their Japanese counterparts) from queries only. Search keeps a candidate only when it holds more than half of the question's content terms, and reports how many weaker matches it left out, so a question with no answer returns nothing- Always quote query terms (
ftsQuery); unquoted,AND,NEAR,:, and-become operators
Verification
- Tests run SQL on a real SQLite database in a temporary directory (
server/test/temp-db.ts). Do not touch~/.sphica bun run verifyincludessql:reach(tests ran every SQL call site inserver/src, exceptLIVE_FILES),sql:live(the CLI and capture as child processes), and the acceptance cases- The acceptance cases are the contract.
server/evals/acceptance/cases.jsonholds the bilingual cases over capture, status, retrieval, injection, review, and glean (server/test/acceptance-cases.test.tsfixes the count per layer);run.tsplays them throughdriver.ts. For a new behavior, add a case first and confirm it fails for the intended reason
Does it work?
Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.
No reviews yet
People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.
- It worked
- Partly
- It didn't
Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.
Your trail
Files you open will collect here, so you can find them again.
Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.
Now do this for your own.
Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.

