@nurix/etna 0.19.53 → 0.19.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/harness-index.json +1 -1
- package/data/harnesses/apollo/codex/agents/bookkeeper.toml +26 -0
- package/data/harnesses/apollo/harness.json +2 -2
- package/data/harnesses/apollo/hooks/awareness.mjs +1 -1
- package/data/harnesses/apollo/hooks/compliance.mjs +54 -23
- package/data/harnesses/apollo/hooks/lib/state.mjs +8 -2
- package/data/harnesses/apollo/rules/database.md +46 -11
- package/data/harnesses/code/codex/agents/bookkeeper.toml +26 -0
- package/data/harnesses/code/harness.json +2 -2
- package/data/harnesses/code/hooks/awareness.mjs +1 -1
- package/data/harnesses/code/hooks/compliance.mjs +54 -23
- package/data/harnesses/code/hooks/lib/state.mjs +8 -2
- package/data/harnesses/code/rules/database.md +46 -11
- package/package.json +2 -2
package/data/harness-index.json
CHANGED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dropped Claude tools: Read, Glob, Grep, Edit, Write, Bash, Skill
|
|
2
|
+
name = "bookkeeper"
|
|
3
|
+
description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
|
|
4
|
+
developer_instructions = '''
|
|
5
|
+
|
|
6
|
+
# Bookkeeper Agent
|
|
7
|
+
|
|
8
|
+
You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the Stop/commit walls hold until the queue is clean or waived.
|
|
9
|
+
|
|
10
|
+
## Source-of-truth order — the repo first, the transcript last
|
|
11
|
+
|
|
12
|
+
Ground every edit in this ladder, in order. Never invent content; a fact you cannot ground is a fact you do not write.
|
|
13
|
+
|
|
14
|
+
1. **`auto:` rows are recomputable from code + docs on any machine — fix directly.** A `dead-glob` row: read the owning doc, find where the files went (`git log --follow`, glob search), update its `owns:` frontmatter. A `header-missing` row: add the file-path header comment in the file's native comment syntax. A `doc-lint` row: fix the finding, or add `<!-- doc-lint-disable: id -->` with a reason beside it when the flagged text is deliberate. An `index-row-missing` row: add the doc's row to `docs/README.md`. A `doc-implicated` row: read the diff of the named source file (`git diff` / `git log -p`), and update the owning doc **only where the diff contradicts documented behavior**; if nothing documented changed, the row's resolution is deleting it.
|
|
15
|
+
2. **`note:` rows — expand strictly from the stated context.** The one-line context after the area is everything the session chose to hand you; land it in the destination the row names (`lands-in:` an owning doc, the backlog, or memory), written in that document's genre.
|
|
16
|
+
3. **A thin note, same machine only** — derive the transcript from the row's `sid=` tail: the session JSONL lives at `~/.claude/projects/{project-dir-slug}/{sid}.jsonl` (the slug is the project path with `/` → `-`). Search it for the row's file path or area and read only that window — never the whole file. The sid is best-effort and dead on other clones — expected; treat an unreadable or missing transcript as "no window".
|
|
17
|
+
4. **Still ungrounded** — replace the row with a `needs-session:` row (`- needs-session: {date} · {what you could not ground} → main session resolves | refs={original row}`) and stop working that row.
|
|
18
|
+
|
|
19
|
+
## Rules
|
|
20
|
+
|
|
21
|
+
- **Never edit frozen records** — committed changelog entries, `Status: Complete|Abandoned` plans, outcome-stamped runbooks. A finding that seems to require one gets a `needs-session:` row instead.
|
|
22
|
+
- **Every touched doc goes through the `documentation` skill's rules** — present tense, two-natures, no history narration in truth documents.
|
|
23
|
+
- **Resolution = deletion.** A finished row is removed from the queue, not annotated. Only `waived:` conversions (a reason plus `expires=YYYY-MM-DD`) and `needs-session:` rows survive your pass.
|
|
24
|
+
- **Never waive on your own judgment** — waiving is the main session's (or the user's) call; your outcomes are fixed, landed, or `needs-session:`.
|
|
25
|
+
- **Report by row**: one line each — the row, the outcome (fixed / landed in {doc} / needs-session), and the files touched. The main session relays this; keep it terse.
|
|
26
|
+
'''
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "apollo",
|
|
3
3
|
"name": "apollo",
|
|
4
|
-
"version": "1.1.
|
|
4
|
+
"version": "1.1.5",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
@@ -15,5 +15,5 @@
|
|
|
15
15
|
"docs/memory/main.md",
|
|
16
16
|
".mcp.json"
|
|
17
17
|
],
|
|
18
|
-
"digest": "
|
|
18
|
+
"digest": "e86ea39dc854"
|
|
19
19
|
}
|
|
@@ -240,7 +240,7 @@ function hasHeaderComment(projectDir, rel) {
|
|
|
240
240
|
|
|
241
241
|
function detectorForFile(projectDir, rel, meta, rows) {
|
|
242
242
|
const owners = owningDocs(projectDir, rel);
|
|
243
|
-
recordTouched(projectDir, rel, owners.map((o) => o.doc));
|
|
243
|
+
recordTouched(projectDir, rel, owners.map((o) => o.doc), meta.sessionId);
|
|
244
244
|
|
|
245
245
|
if (rel.endsWith(".md")) {
|
|
246
246
|
// An edited doc resolves any doc-implicated row that pointed at it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// harness/code/hooks/compliance.mjs — the gate-always-waivable hook module.
|
|
2
2
|
// PreToolUse (Edit|Write|MultiEdit|Bash): deny what is never legitimate (frozen records, protected
|
|
3
3
|
// files, plan-gate skips, changelog-less commits, off-catalog packages), ask where intent matters
|
|
4
|
-
// (branches, live commands
|
|
4
|
+
// (branches, live commands). Stop: block an arc that tries
|
|
5
5
|
// to end with unresolved bookkeeping or an unrecorded plan completion. Every gate fails open — a
|
|
6
6
|
// crash or unreadable repo allows the action; enforcement is never allowed to become an outage.
|
|
7
7
|
|
|
@@ -70,12 +70,16 @@ function openGatedPlans(projectDir) {
|
|
|
70
70
|
if (!status || /Complete|Abandoned/.test(status)) continue;
|
|
71
71
|
const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
|
|
72
72
|
if (!gates) continue; // legacy tolerance
|
|
73
|
-
plans.push({
|
|
73
|
+
plans.push({
|
|
74
|
+
file,
|
|
75
|
+
status,
|
|
76
|
+
gates,
|
|
77
|
+
hasLedger: /^##\s+Test Obligations\b/m.test(text),
|
|
78
|
+
});
|
|
74
79
|
}
|
|
75
80
|
return plans;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
const TEST_FILE_RE = /(^|\/)tests?\/|\.test\.|\.spec\./;
|
|
79
83
|
const SOURCE_LIKE = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|vue|svelte)$/;
|
|
80
84
|
|
|
81
85
|
function fileGate(projectDir, rel, toolName) {
|
|
@@ -109,15 +113,13 @@ function fileGate(projectDir, rel, toolName) {
|
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
}
|
|
116
|
+
// Writing a test file is never an interruption. Deferring tests to the verification gate stays
|
|
117
|
+
// doctrine (`verification.md` § During the loop), but it needs no gate here: what the ledger
|
|
118
|
+
// actually guarantees is enforced at both ends already — a plan may not take implementation
|
|
119
|
+
// edits until its `## Test Obligations` section exists (above), and it may not reach
|
|
120
|
+
// `Status: Complete` until the gate discharges every row (the Stop gate). Policing the *moment*
|
|
121
|
+
// a file appears adds no guarantee those two do not, and it fires hardest at the gate itself,
|
|
122
|
+
// where the ledger's tests are precisely what should be getting written.
|
|
121
123
|
|
|
122
124
|
// Doc placement is never an interruption: an off-map docs/ folder is detected silently by
|
|
123
125
|
// the awareness module (a `doc-placement` auto: row) and resolved by the bookkeeper.
|
|
@@ -272,12 +274,18 @@ function bookkeeperDispatched(transcriptPath) {
|
|
|
272
274
|
|
|
273
275
|
// Plans this session flipped to Complete: Status is Complete AND the file appears in this
|
|
274
276
|
// machine's touched.tsv (written by the awareness detector as edits happen).
|
|
275
|
-
function plansCompletedThisSession(projectDir) {
|
|
277
|
+
function plansCompletedThisSession(projectDir, sessionId) {
|
|
276
278
|
const touched = readSafe(path.join(projectDir, ".claude", "state", "touched.tsv"));
|
|
277
279
|
const touchedPlans = new Set(
|
|
278
280
|
touched
|
|
279
281
|
.split("\n")
|
|
280
|
-
.map((line) => line.split("\t")
|
|
282
|
+
.map((line) => line.split("\t"))
|
|
283
|
+
// THIS session's rows only. touched.tsv is shared by every session in the checkout, so an
|
|
284
|
+
// unfiltered read blocks one session's Stop for a plan another session completed — whose Ask
|
|
285
|
+
// quotes and iterations it does not have and cannot invent. Rows written before the id column
|
|
286
|
+
// existed carry no session and belong to none, so they are skipped rather than assumed.
|
|
287
|
+
.filter((cols) => sessionId && cols[3] === sessionId)
|
|
288
|
+
.map((cols) => cols[1])
|
|
281
289
|
.filter((p) => p && p.startsWith("docs/history/plans/"))
|
|
282
290
|
);
|
|
283
291
|
const out = [];
|
|
@@ -288,6 +296,30 @@ function plansCompletedThisSession(projectDir) {
|
|
|
288
296
|
return out;
|
|
289
297
|
}
|
|
290
298
|
|
|
299
|
+
// Whether some changelog entry already accounts for this plan.
|
|
300
|
+
//
|
|
301
|
+
// COMMITTED COUNTS. The test used to be `git status --porcelain docs/history/changelog/` — i.e. "is
|
|
302
|
+
// there a DIRTY file there" — which a properly committed entry can never satisfy: committing cleans
|
|
303
|
+
// the tree, so the gate re-fired immediately after the one action that discharged it, and the only
|
|
304
|
+
// way to silence it was to leave an uncommitted file lying in the changelog directory.
|
|
305
|
+
//
|
|
306
|
+
// An entry names its plan by slug (the template links `../plans/<slug>.md`), so the slug is what is
|
|
307
|
+
// searched for. A dirty file still counts too: a draft being written this moment has not necessarily
|
|
308
|
+
// named its plan yet, and that was the original check's one real job.
|
|
309
|
+
function planHasChangelogEntry(projectDir, planRel) {
|
|
310
|
+
if ((git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "").trim()) return true;
|
|
311
|
+
|
|
312
|
+
const slug = path.basename(planRel, ".md");
|
|
313
|
+
const dir = path.join(projectDir, "docs", "history", "changelog");
|
|
314
|
+
let names = [];
|
|
315
|
+
try {
|
|
316
|
+
names = fs.readdirSync(dir);
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
|
|
321
|
+
}
|
|
322
|
+
|
|
291
323
|
function stopGate(projectDir, payload) {
|
|
292
324
|
if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
|
|
293
325
|
|
|
@@ -307,15 +339,14 @@ function stopGate(projectDir, payload) {
|
|
|
307
339
|
};
|
|
308
340
|
}
|
|
309
341
|
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
342
|
+
const undocumented = plansCompletedThisSession(projectDir, payload.session_id).filter(
|
|
343
|
+
(rel) => !planHasChangelogEntry(projectDir, rel)
|
|
344
|
+
);
|
|
345
|
+
if (undocumented.length > 0) {
|
|
346
|
+
return {
|
|
347
|
+
decision: "block",
|
|
348
|
+
reason: `Plan \`${undocumented[0]}\` reached Status: Complete this session with no changelog entry drafted. Write the draft now (documentation skill → templates/changelog-entry.md) — the Ask quotes, rejected iterations, and skill verdicts that fill it exist only in this session and die with it.`,
|
|
349
|
+
};
|
|
319
350
|
}
|
|
320
351
|
|
|
321
352
|
return null;
|
|
@@ -150,8 +150,14 @@ export function recordAsked(projectDir, sessionId, key) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
|
|
153
|
-
|
|
153
|
+
//
|
|
154
|
+
// THE SESSION ID IS THE FOURTH COLUMN, and it is what lets a reader tell one session's edits from
|
|
155
|
+
// another's. The file is shared by every session running in this checkout, so without it a gate that
|
|
156
|
+
// asks "did I touch this?" can only answer "did anyone?" — and blocks one session for another's
|
|
157
|
+
// work. Appended rather than inserted so rows written before this column existed still parse: the
|
|
158
|
+
// path stays at index 1, and a missing id reads as `undefined`, which belongs to no session.
|
|
159
|
+
export function recordTouched(projectDir, relPath, docs, sessionId) {
|
|
154
160
|
const dir = stateDir(projectDir);
|
|
155
|
-
const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\n`;
|
|
161
|
+
const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\t${sessionId || "-"}\n`;
|
|
156
162
|
fs.appendFileSync(path.join(dir, "touched.tsv"), line);
|
|
157
163
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Database
|
|
2
2
|
|
|
3
|
-
Fixed conventions for relational schemas in **every** Nurix project — what earns a table, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
|
|
3
|
+
Fixed conventions for relational schemas in **every** Nurix project — what earns a table, where a fact lives once it does, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
|
|
4
4
|
|
|
5
5
|
Written for SQLite / Cloudflare D1 + Drizzle (the concrete types below). The names and key vocabulary are engine-agnostic — a Postgres project keeps the same column names and semantics with native types (`uuid`, `timestamptz`, `boolean`).
|
|
6
6
|
|
|
@@ -10,29 +10,64 @@ A table is the most expensive home for data — a migration to change its shape,
|
|
|
10
10
|
|
|
11
11
|
| A table is earned by… | Lacking all of these, the data belongs in… |
|
|
12
12
|
| -------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
13
|
-
| identity minted at **runtime**
|
|
14
|
-
| variation **per tenant / user**
|
|
13
|
+
| identity minted at **runtime** — a session, an upload | — |
|
|
14
|
+
| variation **per tenant / user** — a workspace's theme | — |
|
|
15
15
|
| **independent addressing** — queried, indexed, or FK'd in isolation | a **JSON column** on its parent row |
|
|
16
16
|
| **concurrency-safe** writes — racing writers must not clobber a shared blob | — |
|
|
17
17
|
| a **DB-enforced invariant** — immutability trigger, unique, atomic boundary | — |
|
|
18
|
-
| dev-authored, published on deploy, identical for every tenant
|
|
19
|
-
| exactly recomputable from other rows
|
|
20
|
-
| process state
|
|
21
|
-
| owned by another system-of-record
|
|
18
|
+
| dev-authored, published on deploy, identical for every tenant — plan tiers | **config / a constants file**, validated at build |
|
|
19
|
+
| exactly recomputable from other rows — a tenant's usage total | a **cache** (KV / edge), recomputed on demand |
|
|
20
|
+
| process state dead once the request completes — a retry count | a **log or workflow engine**, never the domain DB |
|
|
21
|
+
| owned by another system-of-record — the payment processor's invoice | a **reference column** — store the id, not the data |
|
|
22
22
|
|
|
23
23
|
Each right-column route is one test: process state — would a product feature break if this vanished the instant the request finished?; derived output — exactly recomputable from other rows + code?; un-addressed detail — is the row ever `SELECT`ed / `JOIN`ed / FK'd alone?; another system's record — does a specialized service (chat, telemetry, object store) already own it?
|
|
24
24
|
|
|
25
|
-
- **Static vocabulary or a fixed graph → config, not a table
|
|
25
|
+
- **Static vocabulary or a fixed graph → config, not a table** — HTTP status labels, a fixed onboarding funnel. Surface it in code as a constant/type; reifying it into tables plus a seed makes the file and the tables a two-copy sync problem. The refined boundary is § Vocabulary / lookup tables.
|
|
26
26
|
- **History → one immutable log + a mutable head.** Never shatter versioning into per-entity `*_revisions` / `*_revision_sections` tables — snapshot the coherent set into one append-only versions table; current state is a projection of the newest entry. A table that is just "old rows of another table" collapses into the snapshot.
|
|
27
27
|
- **A surviving table names the invariant it buys** — content-addressed immutable rows buy reproducibility, a monotonic sequence buys ordering/sync, an atomic write boundary buys multi-row atomicity. Drop the requirement and the table drops with it.
|
|
28
28
|
|
|
29
29
|
Table count tracks **requirements, not domain richness** — a rich domain is not entitled to a table per noun.
|
|
30
30
|
|
|
31
|
+
## State, rules, and history
|
|
32
|
+
|
|
33
|
+
**One fact has one home, chosen by what kind of fact it is.** Confusing these produces the most common redundant table in a schema — a queue whose rows restate state the entity already carries.
|
|
34
|
+
|
|
35
|
+
| The fact | Home | Example |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| What the entity **is now** | a **column on the entity's own row** | `documents.state = 'blocked'` |
|
|
38
|
+
| What may be **done** about that state | **code** — the transition map, the UI's affordances | which inputs unblock a document |
|
|
39
|
+
| What **was done**, and by whom | the **append-only audit log** | an `audit_log` row: `document.override_applied` |
|
|
40
|
+
| What a consumer must **replay in order** | the **sequence log** | an `app_events` row keyed `(app_id, sequence)` |
|
|
41
|
+
|
|
42
|
+
- **State becomes a column the moment anything selects on it.** `WHERE state = 'blocked'` takes an index; `WHERE json_extract(metadata, '$.state') = 'blocked'` scans the table and is invisible to every schema check. JSON keeps only what § What earns a table leaves it — detail nothing addresses alone.
|
|
43
|
+
- **A pending-work table mirroring row state is the anti-pattern** — `*_actions`, `*_inbox`, `*_flags`. The tell: resolving one of its rows also updates the entity, so two rows answer one question and are free to disagree. The *open question* is a column beside the state (at most one per row ⇒ a column, § A junction spells many-to-many); the *answer given* is an audit-log row.
|
|
44
|
+
- **Legal transitions live in code, never as rows** — a `status_transitions` table is a state machine the database cannot enforce and the code must re-implement anyway to use.
|
|
45
|
+
|
|
46
|
+
**The two logs are not substitutes**, and no one table serves both:
|
|
47
|
+
|
|
48
|
+
| | Sequence log | Audit log |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| Answers | what happened here, **in order** | who did what, **and whether it succeeded** |
|
|
51
|
+
| Ordinal | a **gapless per-entity `sequence`**, allocated from a counter | none — nothing replays it |
|
|
52
|
+
| Scope | one entity, always | any — **including none**: a sign-up predates every tenant |
|
|
53
|
+
| Records | only what **happened** | **attempts too** — `denied`, `failure` |
|
|
54
|
+
| Read by | cursors replaying forward | investigations, at any age |
|
|
55
|
+
|
|
56
|
+
The bottom two rows are why neither can hold the other's rows: an event at a scope with no entity has no sequence to take, and a refused attempt in a replay stream would have consumers apply something that never happened.
|
|
57
|
+
|
|
58
|
+
### The audit log's own shape
|
|
59
|
+
|
|
60
|
+
**An audit log outlives every row it names, so it stores what it needs to stay readable without them.** Both consequences are missing from most first drafts:
|
|
61
|
+
|
|
62
|
+
- **Denormalize the display at write time** — `actor_label`, `target_label` beside `actor_id` / `target_id`. Ids alone leave the log intact but unreadable once the person or record is deleted, which is the one thing it exists to survive.
|
|
63
|
+
- **Record the attempt, not the success** — `outcome` (`success | failure | denied`) plus a `reason`. Refused sign-ins and permission denials are most of what anyone opens an audit log for; without the column they cannot be written at all.
|
|
64
|
+
- **Keep the columns product-neutral** — `actor_*` · `action` · `outcome` · `target_*` · `scope_path` · `channel` · `source_ip` · `correlation_id` · `changes_json` serve any domain. Per-level tenancy columns tie the log to today's model; a materialized `scope_path` (`workspace:abc/app:def`) makes "everything under this tenant" one prefix scan and survives a new level appearing — at the price of enforcing the hierarchy in code rather than a `CHECK`.
|
|
65
|
+
|
|
31
66
|
## Scope, not parallel tables
|
|
32
67
|
|
|
33
68
|
One entity that exists at several scopes is **one table with a nullable scope column** — never one table per scope, and never one table shared across entity families.
|
|
34
69
|
|
|
35
|
-
- **A table shared by several families carries the union of their columns** —
|
|
70
|
+
- **A table shared by several families carries the union of their columns** — one `settings` table for users, apps, and workspaces. Almost nothing can be `NOT NULL`, the payload lands in JSON beyond every constraint, index, and FK, and a new family widens a table other features already depend on. One table per family.
|
|
36
71
|
- **Two tables declared to have "the same columns" are two schemas maintained by hand** — the drift is silent, surfacing as an incomplete copy, never as an error. Collapse them and discriminate by column.
|
|
37
72
|
- **The scope column is the discriminator** (`app_id IS NULL` = parent scope, set = child scope). Copying a scope is then `INSERT … SELECT` over one table with one column rewritten, so source and target cannot disagree about shape.
|
|
38
73
|
- **A nullable owner column is how a row exists before its owner does** — a pending enrolment or unclaimed record is creatable before the owner is known, which is what lets a `pending_*` / `*_requests` table collapse into the real one.
|
|
@@ -49,7 +84,7 @@ One entity that exists at several scopes is **one table with a nullable scope co
|
|
|
49
84
|
|
|
50
85
|
Three column roles are fixed across every table and every project:
|
|
51
86
|
|
|
52
|
-
- **`id`** — the primary key, **always a UUID** (`TEXT PRIMARY KEY` on D1, `uuid` on Postgres), never a synthetic or natural key (❌ `type_page_button`, `human:default`). Runtime rows mint a UUIDv7; seed rows derive a deterministic UUIDv5 of the natural key so re-seeds stay stable. **Never** mix synthetic and UUID ids in one table.
|
|
87
|
+
- **`id`** — the primary key, **always a UUID** (`TEXT PRIMARY KEY` on D1, `uuid` on Postgres), never a synthetic or natural key (❌ `type_page_button`, `human:default`). Runtime rows mint a UUIDv7; seed rows derive a deterministic UUIDv5 of the natural key so re-seeds stay stable. **Never** mix synthetic and UUID ids in one table. The single exemption is a log whose **composite key is its ordering** (`PRIMARY KEY (<entity>_id, sequence)`), declared at the table that uses it — everything else, logs included, takes a UUIDv7.
|
|
53
88
|
- **`slug`** — the **stable key rows in another scope reference**: `UNIQUE`, lowercase `snake_case`. A slug means the same thing in every scope, so a slug reference survives a copy with no remapping (§ Copying rows between scopes). ❌ Never rename this role `handle`, `key`, `code`, or `name` in another project.
|
|
54
89
|
- **`label`** — the **human display name** — ❌ never `name`, `title`, or `display_name`.
|
|
55
90
|
|
|
@@ -118,6 +153,6 @@ CREATE UNIQUE INDEX <t>_child_slug_unique ON <t> (child_id, slug) WHERE child_
|
|
|
118
153
|
|
|
119
154
|
## Vocabulary / lookup tables
|
|
120
155
|
|
|
121
|
-
A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime; static dev-authored config nobody references by FK stays **config** (§ What earns a table) —
|
|
156
|
+
A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime — a service catalog rendered in a picker; static dev-authored config nobody references by FK stays **config** (§ What earns a table) — a `doc_type` value list. Enumerable is not tabular, and a `type`-style column validated against the constants file beats an FK to a seeded mirror. An earned table has a fixed shape: `id` (UUID) · `slug` (UNIQUE) · `label` · optional `description` · `status` · timestamps.
|
|
122
157
|
|
|
123
158
|
- **Seeds are generated, never hand-written** — the source of truth is a CSV or a shared package; a generator emits the `*.seed.sql` with UUIDv5 ids and an idempotent `ON CONFLICT(slug)` upsert that preserves live counts / admin-tuned columns. ❌ Hand-editing seed SQL — regenerate it.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# dropped Claude tools: Read, Glob, Grep, Edit, Write, Bash, Skill
|
|
2
|
+
name = "bookkeeper"
|
|
3
|
+
description = "Drains docs/operations/bookkeeping.md — performs mechanical fixes (owns: globs, header comments, doc-lint findings, index rows), expands note: rows into doc edits, marks rows resolved. Dispatched by the main session at turn end or commit time; never self-triggers. Triggers on \"drain the bookkeeping queue\", \"dispatch the bookkeeper\", \"/bookkeeper\"."
|
|
4
|
+
developer_instructions = '''
|
|
5
|
+
|
|
6
|
+
# Bookkeeper Agent
|
|
7
|
+
|
|
8
|
+
You drain the shared work queue at `docs/operations/bookkeeping.md`. Each row is one finding; your job is to resolve it — by doing the work it names — and delete the row. You are the asynchronous half of the harness's enforcement loop: the detector appends rows silently, you drain them, and the Stop/commit walls hold until the queue is clean or waived.
|
|
9
|
+
|
|
10
|
+
## Source-of-truth order — the repo first, the transcript last
|
|
11
|
+
|
|
12
|
+
Ground every edit in this ladder, in order. Never invent content; a fact you cannot ground is a fact you do not write.
|
|
13
|
+
|
|
14
|
+
1. **`auto:` rows are recomputable from code + docs on any machine — fix directly.** A `dead-glob` row: read the owning doc, find where the files went (`git log --follow`, glob search), update its `owns:` frontmatter. A `header-missing` row: add the file-path header comment in the file's native comment syntax. A `doc-lint` row: fix the finding, or add `<!-- doc-lint-disable: id -->` with a reason beside it when the flagged text is deliberate. An `index-row-missing` row: add the doc's row to `docs/README.md`. A `doc-implicated` row: read the diff of the named source file (`git diff` / `git log -p`), and update the owning doc **only where the diff contradicts documented behavior**; if nothing documented changed, the row's resolution is deleting it.
|
|
15
|
+
2. **`note:` rows — expand strictly from the stated context.** The one-line context after the area is everything the session chose to hand you; land it in the destination the row names (`lands-in:` an owning doc, the backlog, or memory), written in that document's genre.
|
|
16
|
+
3. **A thin note, same machine only** — derive the transcript from the row's `sid=` tail: the session JSONL lives at `~/.claude/projects/{project-dir-slug}/{sid}.jsonl` (the slug is the project path with `/` → `-`). Search it for the row's file path or area and read only that window — never the whole file. The sid is best-effort and dead on other clones — expected; treat an unreadable or missing transcript as "no window".
|
|
17
|
+
4. **Still ungrounded** — replace the row with a `needs-session:` row (`- needs-session: {date} · {what you could not ground} → main session resolves | refs={original row}`) and stop working that row.
|
|
18
|
+
|
|
19
|
+
## Rules
|
|
20
|
+
|
|
21
|
+
- **Never edit frozen records** — committed changelog entries, `Status: Complete|Abandoned` plans, outcome-stamped runbooks. A finding that seems to require one gets a `needs-session:` row instead.
|
|
22
|
+
- **Every touched doc goes through the `documentation` skill's rules** — present tense, two-natures, no history narration in truth documents.
|
|
23
|
+
- **Resolution = deletion.** A finished row is removed from the queue, not annotated. Only `waived:` conversions (a reason plus `expires=YYYY-MM-DD`) and `needs-session:` rows survive your pass.
|
|
24
|
+
- **Never waive on your own judgment** — waiving is the main session's (or the user's) call; your outcomes are fixed, landed, or `needs-session:`.
|
|
25
|
+
- **Report by row**: one line each — the row, the outcome (fixed / landed in {doc} / needs-session), and the files touched. The main session relays this; keep it terse.
|
|
26
|
+
'''
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"harness": "code",
|
|
3
3
|
"name": "code",
|
|
4
|
-
"version": "3.6.
|
|
4
|
+
"version": "3.6.5",
|
|
5
5
|
"files": [
|
|
6
6
|
".prettierignore",
|
|
7
7
|
".prettierrc.json",
|
|
@@ -10,5 +10,5 @@
|
|
|
10
10
|
"seeds": [
|
|
11
11
|
".mcp.json"
|
|
12
12
|
],
|
|
13
|
-
"digest": "
|
|
13
|
+
"digest": "cc0eb9871a83"
|
|
14
14
|
}
|
|
@@ -240,7 +240,7 @@ function hasHeaderComment(projectDir, rel) {
|
|
|
240
240
|
|
|
241
241
|
function detectorForFile(projectDir, rel, meta, rows) {
|
|
242
242
|
const owners = owningDocs(projectDir, rel);
|
|
243
|
-
recordTouched(projectDir, rel, owners.map((o) => o.doc));
|
|
243
|
+
recordTouched(projectDir, rel, owners.map((o) => o.doc), meta.sessionId);
|
|
244
244
|
|
|
245
245
|
if (rel.endsWith(".md")) {
|
|
246
246
|
// An edited doc resolves any doc-implicated row that pointed at it.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// harness/code/hooks/compliance.mjs — the gate-always-waivable hook module.
|
|
2
2
|
// PreToolUse (Edit|Write|MultiEdit|Bash): deny what is never legitimate (frozen records, protected
|
|
3
3
|
// files, plan-gate skips, changelog-less commits, off-catalog packages), ask where intent matters
|
|
4
|
-
// (branches, live commands
|
|
4
|
+
// (branches, live commands). Stop: block an arc that tries
|
|
5
5
|
// to end with unresolved bookkeeping or an unrecorded plan completion. Every gate fails open — a
|
|
6
6
|
// crash or unreadable repo allows the action; enforcement is never allowed to become an outage.
|
|
7
7
|
|
|
@@ -70,12 +70,16 @@ function openGatedPlans(projectDir) {
|
|
|
70
70
|
if (!status || /Complete|Abandoned/.test(status)) continue;
|
|
71
71
|
const gates = (/^\*\*Gates:\*\*\s*(.+)$/m.exec(text) || [])[1]?.trim();
|
|
72
72
|
if (!gates) continue; // legacy tolerance
|
|
73
|
-
plans.push({
|
|
73
|
+
plans.push({
|
|
74
|
+
file,
|
|
75
|
+
status,
|
|
76
|
+
gates,
|
|
77
|
+
hasLedger: /^##\s+Test Obligations\b/m.test(text),
|
|
78
|
+
});
|
|
74
79
|
}
|
|
75
80
|
return plans;
|
|
76
81
|
}
|
|
77
82
|
|
|
78
|
-
const TEST_FILE_RE = /(^|\/)tests?\/|\.test\.|\.spec\./;
|
|
79
83
|
const SOURCE_LIKE = /\.(m?[jt]sx?|py|rs|go|rb|java|kt|swift|c|h|cpp|hpp|cs|sh|sql|vue|svelte)$/;
|
|
80
84
|
|
|
81
85
|
function fileGate(projectDir, rel, toolName) {
|
|
@@ -109,15 +113,13 @@ function fileGate(projectDir, rel, toolName) {
|
|
|
109
113
|
}
|
|
110
114
|
}
|
|
111
115
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
}
|
|
116
|
+
// Writing a test file is never an interruption. Deferring tests to the verification gate stays
|
|
117
|
+
// doctrine (`verification.md` § During the loop), but it needs no gate here: what the ledger
|
|
118
|
+
// actually guarantees is enforced at both ends already — a plan may not take implementation
|
|
119
|
+
// edits until its `## Test Obligations` section exists (above), and it may not reach
|
|
120
|
+
// `Status: Complete` until the gate discharges every row (the Stop gate). Policing the *moment*
|
|
121
|
+
// a file appears adds no guarantee those two do not, and it fires hardest at the gate itself,
|
|
122
|
+
// where the ledger's tests are precisely what should be getting written.
|
|
121
123
|
|
|
122
124
|
// Doc placement is never an interruption: an off-map docs/ folder is detected silently by
|
|
123
125
|
// the awareness module (a `doc-placement` auto: row) and resolved by the bookkeeper.
|
|
@@ -272,12 +274,18 @@ function bookkeeperDispatched(transcriptPath) {
|
|
|
272
274
|
|
|
273
275
|
// Plans this session flipped to Complete: Status is Complete AND the file appears in this
|
|
274
276
|
// machine's touched.tsv (written by the awareness detector as edits happen).
|
|
275
|
-
function plansCompletedThisSession(projectDir) {
|
|
277
|
+
function plansCompletedThisSession(projectDir, sessionId) {
|
|
276
278
|
const touched = readSafe(path.join(projectDir, ".claude", "state", "touched.tsv"));
|
|
277
279
|
const touchedPlans = new Set(
|
|
278
280
|
touched
|
|
279
281
|
.split("\n")
|
|
280
|
-
.map((line) => line.split("\t")
|
|
282
|
+
.map((line) => line.split("\t"))
|
|
283
|
+
// THIS session's rows only. touched.tsv is shared by every session in the checkout, so an
|
|
284
|
+
// unfiltered read blocks one session's Stop for a plan another session completed — whose Ask
|
|
285
|
+
// quotes and iterations it does not have and cannot invent. Rows written before the id column
|
|
286
|
+
// existed carry no session and belong to none, so they are skipped rather than assumed.
|
|
287
|
+
.filter((cols) => sessionId && cols[3] === sessionId)
|
|
288
|
+
.map((cols) => cols[1])
|
|
281
289
|
.filter((p) => p && p.startsWith("docs/history/plans/"))
|
|
282
290
|
);
|
|
283
291
|
const out = [];
|
|
@@ -288,6 +296,30 @@ function plansCompletedThisSession(projectDir) {
|
|
|
288
296
|
return out;
|
|
289
297
|
}
|
|
290
298
|
|
|
299
|
+
// Whether some changelog entry already accounts for this plan.
|
|
300
|
+
//
|
|
301
|
+
// COMMITTED COUNTS. The test used to be `git status --porcelain docs/history/changelog/` — i.e. "is
|
|
302
|
+
// there a DIRTY file there" — which a properly committed entry can never satisfy: committing cleans
|
|
303
|
+
// the tree, so the gate re-fired immediately after the one action that discharged it, and the only
|
|
304
|
+
// way to silence it was to leave an uncommitted file lying in the changelog directory.
|
|
305
|
+
//
|
|
306
|
+
// An entry names its plan by slug (the template links `../plans/<slug>.md`), so the slug is what is
|
|
307
|
+
// searched for. A dirty file still counts too: a draft being written this moment has not necessarily
|
|
308
|
+
// named its plan yet, and that was the original check's one real job.
|
|
309
|
+
function planHasChangelogEntry(projectDir, planRel) {
|
|
310
|
+
if ((git(projectDir, ["status", "--porcelain", "--", "docs/history/changelog/"]) || "").trim()) return true;
|
|
311
|
+
|
|
312
|
+
const slug = path.basename(planRel, ".md");
|
|
313
|
+
const dir = path.join(projectDir, "docs", "history", "changelog");
|
|
314
|
+
let names = [];
|
|
315
|
+
try {
|
|
316
|
+
names = fs.readdirSync(dir);
|
|
317
|
+
} catch {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
return names.some((name) => name.endsWith(".md") && readSafe(path.join(dir, name)).includes(slug));
|
|
321
|
+
}
|
|
322
|
+
|
|
291
323
|
function stopGate(projectDir, payload) {
|
|
292
324
|
if (payload.stop_hook_active) return null; // the runtime's infinite-loop guard — never re-block
|
|
293
325
|
|
|
@@ -307,15 +339,14 @@ function stopGate(projectDir, payload) {
|
|
|
307
339
|
};
|
|
308
340
|
}
|
|
309
341
|
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
342
|
+
const undocumented = plansCompletedThisSession(projectDir, payload.session_id).filter(
|
|
343
|
+
(rel) => !planHasChangelogEntry(projectDir, rel)
|
|
344
|
+
);
|
|
345
|
+
if (undocumented.length > 0) {
|
|
346
|
+
return {
|
|
347
|
+
decision: "block",
|
|
348
|
+
reason: `Plan \`${undocumented[0]}\` reached Status: Complete this session with no changelog entry drafted. Write the draft now (documentation skill → templates/changelog-entry.md) — the Ask quotes, rejected iterations, and skill verdicts that fill it exist only in this session and die with it.`,
|
|
349
|
+
};
|
|
319
350
|
}
|
|
320
351
|
|
|
321
352
|
return null;
|
|
@@ -150,8 +150,14 @@ export function recordAsked(projectDir, sessionId, key) {
|
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
// Append one tab-separated line to .claude/state/touched.tsv (file → owning doc map for the session).
|
|
153
|
-
|
|
153
|
+
//
|
|
154
|
+
// THE SESSION ID IS THE FOURTH COLUMN, and it is what lets a reader tell one session's edits from
|
|
155
|
+
// another's. The file is shared by every session running in this checkout, so without it a gate that
|
|
156
|
+
// asks "did I touch this?" can only answer "did anyone?" — and blocks one session for another's
|
|
157
|
+
// work. Appended rather than inserted so rows written before this column existed still parse: the
|
|
158
|
+
// path stays at index 1, and a missing id reads as `undefined`, which belongs to no session.
|
|
159
|
+
export function recordTouched(projectDir, relPath, docs, sessionId) {
|
|
154
160
|
const dir = stateDir(projectDir);
|
|
155
|
-
const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\n`;
|
|
161
|
+
const line = `${new Date().toISOString()}\t${relPath}\t${docs.join(",") || "-"}\t${sessionId || "-"}\n`;
|
|
156
162
|
fs.appendFileSync(path.join(dir, "touched.tsv"), line);
|
|
157
163
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Database
|
|
2
2
|
|
|
3
|
-
Fixed conventions for relational schemas in **every** Nurix project — what earns a table, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
|
|
3
|
+
Fixed conventions for relational schemas in **every** Nurix project — what earns a table, where a fact lives once it does, the key vocabulary, foreign-key doctrine, naming, and column types. A cross-project consistency rule: **one concept, one name, everywhere**. General code naming (variables, files, casing) is [`nomenclature.md`](nomenclature.md); this rule owns the database.
|
|
4
4
|
|
|
5
5
|
Written for SQLite / Cloudflare D1 + Drizzle (the concrete types below). The names and key vocabulary are engine-agnostic — a Postgres project keeps the same column names and semantics with native types (`uuid`, `timestamptz`, `boolean`).
|
|
6
6
|
|
|
@@ -10,29 +10,64 @@ A table is the most expensive home for data — a migration to change its shape,
|
|
|
10
10
|
|
|
11
11
|
| A table is earned by… | Lacking all of these, the data belongs in… |
|
|
12
12
|
| -------------------------------------------------------------------------- | ------------------------------------------------------- |
|
|
13
|
-
| identity minted at **runtime**
|
|
14
|
-
| variation **per tenant / user**
|
|
13
|
+
| identity minted at **runtime** — a session, an upload | — |
|
|
14
|
+
| variation **per tenant / user** — a workspace's theme | — |
|
|
15
15
|
| **independent addressing** — queried, indexed, or FK'd in isolation | a **JSON column** on its parent row |
|
|
16
16
|
| **concurrency-safe** writes — racing writers must not clobber a shared blob | — |
|
|
17
17
|
| a **DB-enforced invariant** — immutability trigger, unique, atomic boundary | — |
|
|
18
|
-
| dev-authored, published on deploy, identical for every tenant
|
|
19
|
-
| exactly recomputable from other rows
|
|
20
|
-
| process state
|
|
21
|
-
| owned by another system-of-record
|
|
18
|
+
| dev-authored, published on deploy, identical for every tenant — plan tiers | **config / a constants file**, validated at build |
|
|
19
|
+
| exactly recomputable from other rows — a tenant's usage total | a **cache** (KV / edge), recomputed on demand |
|
|
20
|
+
| process state dead once the request completes — a retry count | a **log or workflow engine**, never the domain DB |
|
|
21
|
+
| owned by another system-of-record — the payment processor's invoice | a **reference column** — store the id, not the data |
|
|
22
22
|
|
|
23
23
|
Each right-column route is one test: process state — would a product feature break if this vanished the instant the request finished?; derived output — exactly recomputable from other rows + code?; un-addressed detail — is the row ever `SELECT`ed / `JOIN`ed / FK'd alone?; another system's record — does a specialized service (chat, telemetry, object store) already own it?
|
|
24
24
|
|
|
25
|
-
- **Static vocabulary or a fixed graph → config, not a table
|
|
25
|
+
- **Static vocabulary or a fixed graph → config, not a table** — HTTP status labels, a fixed onboarding funnel. Surface it in code as a constant/type; reifying it into tables plus a seed makes the file and the tables a two-copy sync problem. The refined boundary is § Vocabulary / lookup tables.
|
|
26
26
|
- **History → one immutable log + a mutable head.** Never shatter versioning into per-entity `*_revisions` / `*_revision_sections` tables — snapshot the coherent set into one append-only versions table; current state is a projection of the newest entry. A table that is just "old rows of another table" collapses into the snapshot.
|
|
27
27
|
- **A surviving table names the invariant it buys** — content-addressed immutable rows buy reproducibility, a monotonic sequence buys ordering/sync, an atomic write boundary buys multi-row atomicity. Drop the requirement and the table drops with it.
|
|
28
28
|
|
|
29
29
|
Table count tracks **requirements, not domain richness** — a rich domain is not entitled to a table per noun.
|
|
30
30
|
|
|
31
|
+
## State, rules, and history
|
|
32
|
+
|
|
33
|
+
**One fact has one home, chosen by what kind of fact it is.** Confusing these produces the most common redundant table in a schema — a queue whose rows restate state the entity already carries.
|
|
34
|
+
|
|
35
|
+
| The fact | Home | Example |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| What the entity **is now** | a **column on the entity's own row** | `documents.state = 'blocked'` |
|
|
38
|
+
| What may be **done** about that state | **code** — the transition map, the UI's affordances | which inputs unblock a document |
|
|
39
|
+
| What **was done**, and by whom | the **append-only audit log** | an `audit_log` row: `document.override_applied` |
|
|
40
|
+
| What a consumer must **replay in order** | the **sequence log** | an `app_events` row keyed `(app_id, sequence)` |
|
|
41
|
+
|
|
42
|
+
- **State becomes a column the moment anything selects on it.** `WHERE state = 'blocked'` takes an index; `WHERE json_extract(metadata, '$.state') = 'blocked'` scans the table and is invisible to every schema check. JSON keeps only what § What earns a table leaves it — detail nothing addresses alone.
|
|
43
|
+
- **A pending-work table mirroring row state is the anti-pattern** — `*_actions`, `*_inbox`, `*_flags`. The tell: resolving one of its rows also updates the entity, so two rows answer one question and are free to disagree. The *open question* is a column beside the state (at most one per row ⇒ a column, § A junction spells many-to-many); the *answer given* is an audit-log row.
|
|
44
|
+
- **Legal transitions live in code, never as rows** — a `status_transitions` table is a state machine the database cannot enforce and the code must re-implement anyway to use.
|
|
45
|
+
|
|
46
|
+
**The two logs are not substitutes**, and no one table serves both:
|
|
47
|
+
|
|
48
|
+
| | Sequence log | Audit log |
|
|
49
|
+
| --- | --- | --- |
|
|
50
|
+
| Answers | what happened here, **in order** | who did what, **and whether it succeeded** |
|
|
51
|
+
| Ordinal | a **gapless per-entity `sequence`**, allocated from a counter | none — nothing replays it |
|
|
52
|
+
| Scope | one entity, always | any — **including none**: a sign-up predates every tenant |
|
|
53
|
+
| Records | only what **happened** | **attempts too** — `denied`, `failure` |
|
|
54
|
+
| Read by | cursors replaying forward | investigations, at any age |
|
|
55
|
+
|
|
56
|
+
The bottom two rows are why neither can hold the other's rows: an event at a scope with no entity has no sequence to take, and a refused attempt in a replay stream would have consumers apply something that never happened.
|
|
57
|
+
|
|
58
|
+
### The audit log's own shape
|
|
59
|
+
|
|
60
|
+
**An audit log outlives every row it names, so it stores what it needs to stay readable without them.** Both consequences are missing from most first drafts:
|
|
61
|
+
|
|
62
|
+
- **Denormalize the display at write time** — `actor_label`, `target_label` beside `actor_id` / `target_id`. Ids alone leave the log intact but unreadable once the person or record is deleted, which is the one thing it exists to survive.
|
|
63
|
+
- **Record the attempt, not the success** — `outcome` (`success | failure | denied`) plus a `reason`. Refused sign-ins and permission denials are most of what anyone opens an audit log for; without the column they cannot be written at all.
|
|
64
|
+
- **Keep the columns product-neutral** — `actor_*` · `action` · `outcome` · `target_*` · `scope_path` · `channel` · `source_ip` · `correlation_id` · `changes_json` serve any domain. Per-level tenancy columns tie the log to today's model; a materialized `scope_path` (`workspace:abc/app:def`) makes "everything under this tenant" one prefix scan and survives a new level appearing — at the price of enforcing the hierarchy in code rather than a `CHECK`.
|
|
65
|
+
|
|
31
66
|
## Scope, not parallel tables
|
|
32
67
|
|
|
33
68
|
One entity that exists at several scopes is **one table with a nullable scope column** — never one table per scope, and never one table shared across entity families.
|
|
34
69
|
|
|
35
|
-
- **A table shared by several families carries the union of their columns** —
|
|
70
|
+
- **A table shared by several families carries the union of their columns** — one `settings` table for users, apps, and workspaces. Almost nothing can be `NOT NULL`, the payload lands in JSON beyond every constraint, index, and FK, and a new family widens a table other features already depend on. One table per family.
|
|
36
71
|
- **Two tables declared to have "the same columns" are two schemas maintained by hand** — the drift is silent, surfacing as an incomplete copy, never as an error. Collapse them and discriminate by column.
|
|
37
72
|
- **The scope column is the discriminator** (`app_id IS NULL` = parent scope, set = child scope). Copying a scope is then `INSERT … SELECT` over one table with one column rewritten, so source and target cannot disagree about shape.
|
|
38
73
|
- **A nullable owner column is how a row exists before its owner does** — a pending enrolment or unclaimed record is creatable before the owner is known, which is what lets a `pending_*` / `*_requests` table collapse into the real one.
|
|
@@ -49,7 +84,7 @@ One entity that exists at several scopes is **one table with a nullable scope co
|
|
|
49
84
|
|
|
50
85
|
Three column roles are fixed across every table and every project:
|
|
51
86
|
|
|
52
|
-
- **`id`** — the primary key, **always a UUID** (`TEXT PRIMARY KEY` on D1, `uuid` on Postgres), never a synthetic or natural key (❌ `type_page_button`, `human:default`). Runtime rows mint a UUIDv7; seed rows derive a deterministic UUIDv5 of the natural key so re-seeds stay stable. **Never** mix synthetic and UUID ids in one table.
|
|
87
|
+
- **`id`** — the primary key, **always a UUID** (`TEXT PRIMARY KEY` on D1, `uuid` on Postgres), never a synthetic or natural key (❌ `type_page_button`, `human:default`). Runtime rows mint a UUIDv7; seed rows derive a deterministic UUIDv5 of the natural key so re-seeds stay stable. **Never** mix synthetic and UUID ids in one table. The single exemption is a log whose **composite key is its ordering** (`PRIMARY KEY (<entity>_id, sequence)`), declared at the table that uses it — everything else, logs included, takes a UUIDv7.
|
|
53
88
|
- **`slug`** — the **stable key rows in another scope reference**: `UNIQUE`, lowercase `snake_case`. A slug means the same thing in every scope, so a slug reference survives a copy with no remapping (§ Copying rows between scopes). ❌ Never rename this role `handle`, `key`, `code`, or `name` in another project.
|
|
54
89
|
- **`label`** — the **human display name** — ❌ never `name`, `title`, or `display_name`.
|
|
55
90
|
|
|
@@ -118,6 +153,6 @@ CREATE UNIQUE INDEX <t>_child_slug_unique ON <t> (child_id, slug) WHERE child_
|
|
|
118
153
|
|
|
119
154
|
## Vocabulary / lookup tables
|
|
120
155
|
|
|
121
|
-
A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime; static dev-authored config nobody references by FK stays **config** (§ What earns a table) —
|
|
156
|
+
A controlled vocabulary earns a **table** only when a user row FKs into it and reads its `label` / `description` at runtime — a service catalog rendered in a picker; static dev-authored config nobody references by FK stays **config** (§ What earns a table) — a `doc_type` value list. Enumerable is not tabular, and a `type`-style column validated against the constants file beats an FK to a seeded mirror. An earned table has a fixed shape: `id` (UUID) · `slug` (UNIQUE) · `label` · optional `description` · `status` · timestamps.
|
|
122
157
|
|
|
123
158
|
- **Seeds are generated, never hand-written** — the source of truth is a CSV or a shared package; a generator emits the `*.seed.sql` with UUIDv5 ids and an idempotent `ON CONFLICT(slug)` upsert that preserves live counts / admin-tuned columns. ❌ Hand-editing seed SQL — regenerate it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/etna",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.54",
|
|
4
4
|
"description": "Bootstrap a project with a Nurix harness — copies the harness's CLAUDE.md, skills, agents, rules, and hooks directly into the project from a bundled payload (no marketplace fetch, no network).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -42,5 +42,5 @@
|
|
|
42
42
|
"agent",
|
|
43
43
|
"plugin"
|
|
44
44
|
],
|
|
45
|
-
"etnaFingerprint": "
|
|
45
|
+
"etnaFingerprint": "70b47e452344"
|
|
46
46
|
}
|