ian-operator SQLite · WAL · foreign keys on 24 tables Folio V of VII

Register of instruments

What the operator remembers, how it is keyed, which columns are allowed to influence the system’s sense of progress, and the rule that nothing durable is ever deleted — only superseded.

Article V — Of the schema and the entries made in it

Article V The schema

5.1

One file

All state lives in a single SQLite file, opened once with WAL journaling, a five-second busy timeout and foreign keys enabled. There is no ORM and no migration framework: schema creation is one idempotent function of CREATE TABLE IF NOT EXISTS statements, and every later addition is a guarded ALTER TABLE … ADD COLUMN whose duplicate-column error is caught and swallowed.

lib/operator.js:412–415the migration idiom
try { db.prepare(`ALTER TABLE operator_progress ADD COLUMN ${col[0]} ${col[1]}`).run(); }
catch (e) { if (!/duplicate column name/i.test(String(e && e.message))) throw e; }

Twenty-four distinct tables are created across the codebase; fifteen of them by the core schema function, six by the runner library’s additive bootstrap, and the rest by the advisor and the voice arbiter.5 Uniqueness is used structurally rather than defensively: several partial unique indexes make an invalid state a hard insert error instead of a condition to be checked for.

5.2

The work model

Intent flows downward — from a confirmed goal model, through goals and projects, to the tasks of a particular day, and from there to a forward plan the loop advances one step at a time. Every arrow in the diagram below is a real column or index in the schema.

Entity relationships of the ian-operator work model The goal model table holds versioned strategy with exactly one active row enforced by a partial unique index. Goals carry an importance weight that flows down to tasks through the serves-goal title. Operator tasks are keyed by day and slug, and carry a deadline, a project, a kernel-computed priority and a legacy hand-set priority. Plan rows are keyed to a goal model version and optionally to a task. Action intents are keyed by day with a unique idempotency key; an approved intent points at a decision row which in turn carries the intent identifier and a content hash. Progress rows record what each tick did, the state hash it saw, and separate verifier columns. Confidence rows and the user world model hang off the same version keys. goal_model version UNIQUE status one 'active' source · model_json confirmed_by · confirmed_at valid_until (bi-temporal) model.goals[ ] title importance 0..1 status · detail stored as JSON inside the version, canonicalised by importance goal_confidence goal_model_version item_type goal | project item_key the title confidence · source UNIQUE(version,type,key) operator_tasks day + slug UNIQUE status · lane · owner · blocker priority hand-set, lower = urgent serves_goal the goal TITLE project · deadline · effort computed_priority higher = urgent priority_explain the last two are volatile: recomputed every read operator_plan goal_model_version task_id nullable step_index · step_text status pending | done | skipped | blocked source user_world_model_snapshots / _beliefs version UNIQUE · one 'active' belief_key UNIQUE per snapshot kind · title · body · confidence evidence_json · tags_json valid_from / valid_until who the person is — separate from the work plan operator_action_intents day + idempotency_key UNIQUE action_type · channel · recipient sender_account · body · title status policy_decision policy_reasons_json approval_decision_id → metadata_json (redacted at rest) operator_decisions day + slug UNIQUE status · risk_level · note decided_by · decided_at intent_id ← binds one intent action_hash ← binds its content an approval is valid only while all of: status approved · author human and allowlisted · intent_id matches · hash still matches operator_progress day · task_id · tick_mode action · tried · failed_reason outcome self-reported state_hash consumed_untrusted expected_effect verified_outcome ← verifier effect_bound ← verifier version keys the plan and the board’s intent plan rows re-key on a version bump a task’s work becomes an intent progress rows quote the state hash they acted on, so the next tick can tell whether the board actually moved (Article VI §6.4) Amber panels are the gate’s own tables. Every other relationship in this diagram is ordinary work state. There is no foreign key from an intent to an approval that the intent itself can write.
Figure 6 — the work model and the gate’s ledger Goal importance is carried down to tasks by title rather than by an invented identifier: the source is explicit that serves_goal holds the goal title, “NO invented ids”. Resolution of a title to a goal goes through the conservative matcher, which refuses ambiguity.

5.3

The priority function

Urgency is computed by a pure kernel function the model cannot reach. It is deliberately separate from — and never overloaded onto — the legacy hand-set priority column, which runs in the opposite direction.

lib/operator.js:1087–1104computePriority
//   urgent     = deadline proximity (no deadline -> 0.5 neutral; monotone-decreasing in days-left)
//   important  = goalImportance (0..1) of task.serves_goal; default mid 0.5 when absent/unknown
//   ready      = (status !== 'blocked' && !blocker) ? 1 : 0  -> a blocked task sinks to 0
urgent = daysLeft <= 0 ? 1 : clamp01(1 / (1 + daysLeft));
const computed_priority = Number((urgent * important * ready).toFixed(4));

Each computation also writes a human-readable explanation of itself into priority_explain — for instance urgent=0.33(deadline 2.0d)×important=0.80(goal:…)×ready=1 ⇒ 0.264 — so the ranking is auditable without re-deriving it. A blocked task multiplies to zero rather than being filtered out, which keeps it visible in the ordering while sinking it.

5.4

The state hash, and what is kept out of it

A short SHA-1 over the salient board state lets a tick tell whether anything actually moved since the last one. It is computed over exactly four projections: each task as id:status:priority:blocker, each decision as id:status, each intent as id:status:policy_decision, and the identifier of the most recent instruction.

What is excluded matters more than what is included. Two categories are deliberately kept out, each with a comment explaining the failure that inclusion would cause:

  • computed_priorityRecomputed on every read. Hashing it “would forge per-tick board churn into the no_progress_streak / board_unchanged_streak signal and disarm the stall monitor.”
  • observations, salienceThe perceiving layer is volatile by construction; it is loaded after the hash is computed, so it can never enter it.

The same discipline governs the outcome vocabulary. An intent that the gate allowed but that nothing has executed records the outcome staged, which is classified non-advancing on purpose: “an ‘allowed’ intent that nothing fires must NOT reset the no-progress streak, else the loop looks productive while the board never moves.”

5.5

Core tables

The fifteen tables created by the core schema function
TableHoldsNotable constraint
operator_briefingsOne row per operator day: title, summary, meeting and execution status.day is the primary key
operator_tasksThe board: title, lane, owner, status, blocker, deadline, goal served.UNIQUE(day, slug)
operator_decisionsDecisions awaiting or carrying a verdict, including approvals.UNIQUE(day, slug); carries intent_id and action_hash
operator_commentsInstructions and comments, with the author’s subject.indexed by day
operator_auditEvery consequential event: intents, approvals, gate passes, executor moves.append-only in practice
operator_runsAutonomous run records with heartbeat, owner process and host.partial unique index: one running run per day
operator_action_intentsThe gate’s ledger.partial unique index on (day, idempotency_key)
operator_progressWhat each tick did, plus the verifier’s separate verdict columns.indexed by day and by task
operator_learningsDistilled candidate lessons, never auto-promoted.bi-temporal; deduplicated by content hash
operator_reflectionsShort self-critiques after a failed tick.ephemeral; pruned to the last three per goal
operator_observationsCheap world and self deltas from the perceiving tick.untrusted DEFAULT 1; UNIQUE(day, source, delta_hash)
operator_feedback_signalsImplicit human facts: a revert, a re-ask, a dismissal, an approval latency.never read by the model
operator_interrupt_budgetThe persisted token bucket for unsolicited contact.day is the primary key
user_world_model_snapshotsReviewable versions of the model of the person.partial unique index: one active snapshot
user_world_model_beliefsTyped, evidence-carrying beliefs attached to a snapshot.UNIQUE(snapshot_version, belief_key)

5.6

Bi-temporality, and what may be deleted

Durable rows are never destructively rewritten. A superseded goal model, an outdated belief, a retired lesson: each is closed by stamping valid_until, so history is preserved and every change is reversible. The world-model documentation states the resulting rule plainly — older active snapshots are “marked superseded, never deleted”, and “draft imports do not change the active model. Activation is explicit.”

Bulk deletion exists, but only for high-volume ephemeral logs, and it is fenced by three hard floors: never prune inside the retention window; always keep at least the most recent N progress rows regardless of age, because the cross-day streak logic reads that window and “must NEVER be starved by pruning”; and never touch the durable tables at all. The durable exclusion list is enumerated in the source: goal models, confidence, decisions, action intents, learnings, plans, memory and the state of play.

5.7

The day, and where it comes from

Almost every table is keyed by an operator day rather than a timestamp, and that day is derived from the profile’s timezone — not from the host’s clock, and not from UTC. The runner library and the state layer resolve it through the same profile-timezone source, and a cross-cutting test asserts the two agree at a clock reading near the UTC boundary, so a tick started late at night cannot land on a different day from the gate that judged it.

The same concern shapes the executor: it scans a rolling trailing window of days rather than only today, “so a cross-midnight approval/claim is never invisible.” Three days is the shipped default, and it is environment-overridable.

Notes

  1. The section header in the source reads // --- schema (8 tables) ---; the function beneath it now issues fifteen CREATE TABLE statements. The comment is stale, not the code.