@stackbone/cli 0.3.3 → 0.3.5

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.
@@ -0,0 +1,99 @@
1
+ -- 0047_prompts_owner.sql — a prompt belongs to ONE workflow or ONE deep agent,
2
+ -- not to the whole installation (STA-398).
3
+ --
4
+ -- 0013 made `key` a workspace-wide PRIMARY KEY. That reads fine with three
5
+ -- prompts and breaks the moment two entries want the same obvious name: the
6
+ -- sign-up workflow's `welcome_email` and the reactivation workflow's
7
+ -- `welcome_email` are different letters, and under the old key the second one
8
+ -- could not be written at all — `prompts_already_exists`, with the operator's
9
+ -- only way out being to invent `welcome_email_2`. Identity becomes the PAIR
10
+ -- (owner, key), where the owner is `(owner_kind, owner_name)`.
11
+ --
12
+ -- The vocabulary is the one the box already uses for the same idea. A guardrail
13
+ -- attachment (0036) is scoped to an `agent` or a `workflow` unit by NAME, and a
14
+ -- run (0037) records its `entity_kind` for the same reason: a deep agent and a
15
+ -- workflow may legitimately share a name, so the kind is part of the identity.
16
+ -- Prompts drop 0036's third value — there is no workspace-wide prompt, because
17
+ -- a prompt with no owner is exactly the thing this migration exists to remove.
18
+ --
19
+ -- Deliberately NO CHECK constraint on `owner_kind`, following 0036 and 0037: a
20
+ -- box running an older build must be able to STORE a kind it cannot resolve
21
+ -- rather than reject the write and lose the operator's text. An unrecognised
22
+ -- kind simply never matches a read, which is the safe failure.
23
+ --
24
+ -- `owner_name` is an unconstrained free string like 0036's `unit_id`, NOT the
25
+ -- lowercase slug `key` is validated against on the wire: a workflow named
26
+ -- `sendWelcomeEmail` is a legitimate name the guardrails surface already
27
+ -- accepts for the identical concept, and a stricter column here would refuse to
28
+ -- store a prompt for a workflow that exists.
29
+ --
30
+ -- DROP + CREATE rather than a widening ALTER, on purpose. There is no deployed
31
+ -- installation to preserve, and the alternative — adding two NOT NULL columns to
32
+ -- rows that carry no owner — would need an invented default owner for the
33
+ -- backfill, which is a guess about whose prompt each row was. Rebuilding the two
34
+ -- tables keeps the schema honest instead of encoding that guess.
35
+
36
+ DROP TABLE IF EXISTS stackbone_platform.prompt_versions;
37
+ DROP TABLE IF EXISTS stackbone_platform.prompts;
38
+
39
+ CREATE TABLE stackbone_platform.prompts (
40
+ -- The owner's kind: `agent` (a deep agent) or `workflow`. See above for why
41
+ -- there is no CHECK and no `workspace` member.
42
+ owner_kind text NOT NULL,
43
+ -- The owner's NAME, as the bundle declares it.
44
+ owner_name text NOT NULL,
45
+ key text NOT NULL,
46
+ name text NOT NULL,
47
+ description text,
48
+ -- The version number considered live. Always points at an existing row in
49
+ -- `prompt_versions` for this (owner, key).
50
+ current_version integer NOT NULL CHECK (current_version >= 1),
51
+ metadata jsonb,
52
+ created_by_email text,
53
+ updated_by_email text,
54
+ created_at timestamptz NOT NULL DEFAULT now(),
55
+ updated_at timestamptz NOT NULL DEFAULT now(),
56
+ -- Soft delete. Active prompts have `deleted_at IS NULL`; reads filter on it
57
+ -- so the rows survive for the retention window.
58
+ deleted_at timestamptz,
59
+ PRIMARY KEY (owner_kind, owner_name, key)
60
+ );
61
+
62
+ CREATE TABLE stackbone_platform.prompt_versions (
63
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
64
+ -- All three identity columns, not a widened `prompt_key`: the parent's key
65
+ -- is composite now, so a composite FK needs the child to carry every part of
66
+ -- it. Without them a version row could not say which owner's chain it is in.
67
+ owner_kind text NOT NULL,
68
+ owner_name text NOT NULL,
69
+ prompt_key text NOT NULL,
70
+ version integer NOT NULL CHECK (version >= 1),
71
+ content text NOT NULL,
72
+ -- Pre-computed list of the `{{vars}}` referenced by `content`, so Studio's
73
+ -- diff view can highlight added/removed variables without re-parsing.
74
+ vars_extracted jsonb NOT NULL DEFAULT '[]'::jsonb,
75
+ -- Source version when this row was created by a rollback/promote; NULL for
76
+ -- a regular edit.
77
+ rolled_back_from integer,
78
+ created_by_email text,
79
+ created_at timestamptz NOT NULL DEFAULT now(),
80
+ FOREIGN KEY (owner_kind, owner_name, prompt_key)
81
+ REFERENCES stackbone_platform.prompts (owner_kind, owner_name, key)
82
+ ON DELETE CASCADE
83
+ );
84
+
85
+ -- One version number per (owner, key) — the race backstop behind the
86
+ -- `MAX(version) + 1` the application assigns under a per-prompt advisory lock.
87
+ -- The lock key folds the owner in for the same reason this index leads with it.
88
+ CREATE UNIQUE INDEX IF NOT EXISTS prompt_versions_owner_key_version_idx
89
+ ON stackbone_platform.prompt_versions (owner_kind, owner_name, prompt_key, version);
90
+
91
+ -- Drives the history list (newest first) for one owner's key.
92
+ CREATE INDEX IF NOT EXISTS prompt_versions_owner_key_version_desc_idx
93
+ ON stackbone_platform.prompt_versions (owner_kind, owner_name, prompt_key, version DESC);
94
+
95
+ -- The list endpoint orders active prompts by owner, then key, so a screen shows
96
+ -- each owner's prompts together.
97
+ CREATE INDEX IF NOT EXISTS prompts_active_owner_key_idx
98
+ ON stackbone_platform.prompts (owner_kind, owner_name, key)
99
+ WHERE deleted_at IS NULL;
@@ -0,0 +1,58 @@
1
+ -- 0048_prompt_publish.sql — writing a prompt and publishing it are two steps
2
+ -- (STA-400).
3
+ --
4
+ -- Until now the version that ran was always the highest one that existed: the
5
+ -- head's pointer only ever moved forward (`GREATEST(current_version, $n)`), so
6
+ -- saving an edit put it straight in front of the agent, and going back to an
7
+ -- earlier wording had to COPY that wording into a brand-new version number. An
8
+ -- operator debugging a run then saw `v7` in the logs and `v3`'s text in the
9
+ -- prompt, with nothing on screen tying the two together.
10
+ --
11
+ -- The head now carries two numbers instead of one:
12
+ --
13
+ -- * `latest_version` — the newest version anyone has WRITTEN. This is the
14
+ -- old `current_version`, renamed to say what it always
15
+ -- actually held.
16
+ -- * `published_version` — the one that RUNS. Nullable, because "nothing is
17
+ -- published" is a real state: a prompt whose pointer
18
+ -- has been cleared reads as having no content, which
19
+ -- is what makes deleting it (and its versions)
20
+ -- possible at all.
21
+ --
22
+ -- Publishing moves `published_version` onto a version that already exists. It
23
+ -- never inserts, so the number an operator picked is the number the logs show.
24
+ -- The history stays append-only for EDITS — every save is still an immutable new
25
+ -- row under the unique `(owner, key, version)` index from 0047.
26
+ --
27
+ -- `rolled_back_from` goes with the copy it recorded. It existed to stamp "this
28
+ -- row was born from v3" on the duplicate a rollback inserted; publishing inserts
29
+ -- nothing, so there is no row left to stamp and the column would be NULL on
30
+ -- every future version. Dropped rather than left as a field that can only ever
31
+ -- answer "no".
32
+ --
33
+ -- Both numbers are deliberately left WITHOUT a foreign key onto
34
+ -- `prompt_versions`. A composite FK would force the create path to insert the
35
+ -- head, then the version, then update the head — three statements where two do,
36
+ -- for an invariant the routes already enforce (a publish validates its target
37
+ -- first, and the published version cannot be deleted).
38
+
39
+ ALTER TABLE stackbone_platform.prompts
40
+ RENAME COLUMN current_version TO latest_version;
41
+
42
+ -- Nullable now: deleting a prompt's last draft leaves a head with no versions at
43
+ -- all, which reads as no content rather than as a broken row.
44
+ ALTER TABLE stackbone_platform.prompts
45
+ ALTER COLUMN latest_version DROP NOT NULL;
46
+
47
+ ALTER TABLE stackbone_platform.prompts
48
+ ADD COLUMN IF NOT EXISTS published_version integer CHECK (published_version >= 1);
49
+
50
+ -- Everything written before this migration ran under "the newest version is the
51
+ -- live one", so that is exactly what was published. Without the backfill every
52
+ -- existing prompt would go dark on deploy.
53
+ UPDATE stackbone_platform.prompts
54
+ SET published_version = latest_version
55
+ WHERE published_version IS NULL;
56
+
57
+ ALTER TABLE stackbone_platform.prompt_versions
58
+ DROP COLUMN IF EXISTS rolled_back_from;
@@ -0,0 +1,46 @@
1
+ -- 0049_run_steps_annotations.sql — give a `run_steps` row somewhere to carry what
2
+ -- the SYSTEM observed about the step, next to (never inside) what the creator's
3
+ -- code passed through it. First tenant: which prompt, whose, and at what version
4
+ -- governed the step (STA-404).
5
+ --
6
+ -- WHY A NEW COLUMN AT ALL. `run_steps` has exactly three JSON columns — `input`,
7
+ -- `output` and `error` — and all three hold the CREATOR's payload: the arguments
8
+ -- a tool was called with, what it answered, how it broke. Folding an annotation
9
+ -- into any of them would mix two things that are read for different reasons and
10
+ -- have different lifetimes, and would show up in the trace viewer's Inspector as
11
+ -- if the creator had returned it. The only other free slot in the schema is
12
+ -- `runs.metadata`, which hangs off the RUN and not the step (and is already taken
13
+ -- by the eval seal), so it cannot answer "what ran in THIS step".
14
+ --
15
+ -- WHY `annotations` AND NOT `prompt_uses`. The next per-step annotation — a
16
+ -- guardrail's verdict fold, a retry note, whatever it turns out to be — should
17
+ -- not need a fourth ALTER on this table. So the column is one jsonb OBJECT whose
18
+ -- top-level keys name the annotation kinds, and this feature owns exactly one of
19
+ -- them:
20
+ --
21
+ -- {"prompts": [{"ownerKind": "workflow", "ownerName": "qualify-lead",
22
+ -- "key": "welcome", "version": 3, "reason": null}]}
23
+ --
24
+ -- `reason` is null on a healthy read and names the degradation otherwise
25
+ -- (`not_found` / `not_published` / `deleted` / `missing_var` / `no_owner` /
26
+ -- `unavailable`); `version` is null exactly when `reason` is not. The wire shape
27
+ -- is `runStepPromptUseSchema` in `@stackbone/validators`, the one schema the box,
28
+ -- the emulator and Studio all read.
29
+ --
30
+ -- NULLABLE, no default, no backfill. Every row already written keeps reading back
31
+ -- with the column NULL, and the read path maps NULL to "this step read no
32
+ -- prompts" — so a run that used none looks exactly as it did before this
33
+ -- migration. There is nothing deployed to migrate, but a `DEFAULT '{}'` would
34
+ -- still have rewritten every existing row for no gain.
35
+ --
36
+ -- No index: this is read only alongside the row it annotates (the step listing
37
+ -- already selects by `run_id`), never searched across runs.
38
+ --
39
+ -- The pg_notify trigger (`notify_run_step_event`) streams the row's identity and
40
+ -- status, not its columns, so no trigger change is needed.
41
+
42
+ ALTER TABLE stackbone_platform.run_steps
43
+ ADD COLUMN IF NOT EXISTS annotations jsonb;
44
+
45
+ COMMENT ON COLUMN stackbone_platform.run_steps.annotations IS
46
+ 'System observations about this step, keyed by annotation kind. `prompts` holds the prompt reads (STA-404). Never the creator payload — that is input/output/error.';
@@ -0,0 +1,30 @@
1
+ -- 0050_run_steps_model_provider.sql — give the model its own home on a run step.
2
+ --
3
+ -- Until now the model id had nowhere to live, so it shared the `name` column with
4
+ -- whatever else each writer put there: a direct `client.ai` call stores the model
5
+ -- id, a deep-agent invocation stores the AGENT's name. Both read back as "name",
6
+ -- and nothing on the row says which of the two it is — so the trace viewer could
7
+ -- only print `name` and hope.
8
+ --
9
+ -- Two nullable text columns fix that. `name` keeps meaning whatever each writer
10
+ -- already means by it; it simply stops being where a reader looks for the model.
11
+ --
12
+ -- `provider` is DERIVED from the model id's prefix, never guessed. The ids the box
13
+ -- speaks are OpenRouter-style `vendor/model`, so `openai/gpt-4o-mini` yields
14
+ -- `openai`, and an id with no prefix leaves the column NULL rather than inventing
15
+ -- one. `openrouter` is never stored: it is the gateway that routed the call, not
16
+ -- the provider that served it.
17
+ --
18
+ -- Both columns are nullable with no default and no CHECK, because every row
19
+ -- written before this migration has neither and must stay readable: the trace
20
+ -- viewer falls back to `name` when `model` is null.
21
+ --
22
+ -- Viewer posture is UNCHANGED. 0005 grants SELECT on the TABLE
23
+ -- (`GRANT SELECT ON ALL TABLES IN SCHEMA stackbone_platform`), which covers every
24
+ -- column the table ever grows, and no column-level revoke exists to undo it. So a
25
+ -- new column needs no grant of its own — the DB Explorer sees these two the moment
26
+ -- they exist.
27
+
28
+ ALTER TABLE stackbone_platform.run_steps
29
+ ADD COLUMN IF NOT EXISTS model text,
30
+ ADD COLUMN IF NOT EXISTS provider text;
@@ -0,0 +1,137 @@
1
+ -- 0051_logs.sql — the box's OWN log table (PLA-741, parent PLA-231).
2
+ --
3
+ -- Numbered 0051 against `main`, whose last migration is 0050. The runner keys
4
+ -- `schema_migrations` on the four-digit prefix alone, so two branches that both
5
+ -- take the next free number do not merely collide untidily: the loser applies its
6
+ -- DDL and then dies on the journal's primary key, and the retry reports a
7
+ -- checksum mismatch that points the operator at the wrong problem.
8
+ -- `scripts/check-migration-numbers.mjs` (PLA-735) is the pre-merge gate for that;
9
+ -- renumber against the tip you are merging into, not the one you branched from.
10
+ -- The 0046 → 0050 gap is legal — the migrator applies a late lower number on its
11
+ -- next run, which is why the unmerged 0047-0049 of another branch are harmless.
12
+ --
13
+ -- ONE PLAIN TABLE, not a partitioned one. Retention here is a DELETE by age, and
14
+ -- a box's log volume is bounded by the pipe's own four brakes (a per-line size
15
+ -- cap, a per-run line cap, a per-box daily cap — `log-pipe.ts`). Partitioning
16
+ -- buys a `DROP PARTITION` instead of a batched `DELETE`, at the price of a
17
+ -- partition maintenance job that has to run BEFORE the writes it serves; a box
18
+ -- whose maintenance job missed a tick would start rejecting log lines, which is
19
+ -- exactly the failure mode this feature exists to avoid. Every read and write
20
+ -- goes through the log-domain port (`logs-store.ts`), so moving to partitions
21
+ -- later is a change behind that seam with no data migration and no caller edit.
22
+ --
23
+ -- NO WORKSPACE COLUMN. The box has no honest source for one: it is a single
24
+ -- installation, and the only tenant identifier it ever sees arrives per request
25
+ -- on a JWT claim, not on the boot line it is writing at the time. One box is one
26
+ -- installation, so the scope of every row is the database it lives in. A column
27
+ -- filled with a constant would invite a reader to filter on it.
28
+ --
29
+ -- ts — when the line was written, from the pipe's clock. The
30
+ -- retention sweep and every read order by it.
31
+ -- level — Pino's NUMERIC level (10 trace … 60 fatal), matching the
32
+ -- record the pipe builds and the tail's `>=` filter. A text
33
+ -- level would make "warn and above" a CASE expression.
34
+ -- stream — which of the process's two streams the line was written to.
35
+ -- message — the line itself, already scrubbed and already capped by the
36
+ -- pipe. The DDL imposes no length: the cap is a policy the box
37
+ -- owns, and a column limit would reject rather than truncate.
38
+ -- run_id — the run the line belongs to. NULL for a boot line, a
39
+ -- lifecycle line, a recurring job's line.
40
+ --
41
+ -- DELIBERATELY NOT A FOREIGN KEY to `runs`, unlike `run_steps`.
42
+ -- Two ordinary sequences break under one: the first lines of a
43
+ -- run are written before its `runs` row is inserted, and a chat
44
+ -- turn whose start FAILED goes on logging with no run row that
45
+ -- will ever exist. An FK turns both into a rejected INSERT —
46
+ -- losing exactly the lines an operator opens the screen to
47
+ -- read. It would also chain this table into the runs table's
48
+ -- own retention, which is a separate policy.
49
+ -- step_id — the ENGINE's external step id, not a projected
50
+ -- `run_steps.id` uuid. Free text, minted outside Postgres.
51
+ -- invocation_id — the World run id of a workflow invocation (the `trace_id` the
52
+ -- pipe stamps). Also free text and also not a key.
53
+ -- fields — the producer's own structured fields, minus the canonical
54
+ -- ones this table has columns for. JSONB rather than JSON so a
55
+ -- later slice can index a hot key without a rewrite.
56
+ -- truncated — the pipe hit its per-line cap and stored the head. A flag
57
+ -- rather than a silent short message: a reader has to be able
58
+ -- to tell a short line from a cut one.
59
+ -- masked_count — how many spans the scrubber removed. A COUNT, never the
60
+ -- values: it lets a reader tell "nothing here was secret" from
61
+ -- "three things were taken out of this".
62
+ -- subject_id — RESERVED for the compliance slice (PLA-718), which needs a
63
+ -- data-subject handle to answer an erasure request. Written by
64
+ -- nobody today. It ships now because adding a column to a table
65
+ -- holding a month of log rows is a rewrite of the whole heap,
66
+ -- and this is the cheapest moment it will ever be.
67
+ CREATE TABLE IF NOT EXISTS stackbone_platform.logs (
68
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
69
+ ts timestamptz NOT NULL DEFAULT now(),
70
+ level integer NOT NULL,
71
+ stream text NOT NULL DEFAULT 'stdout',
72
+ message text NOT NULL,
73
+ run_id uuid,
74
+ step_id text,
75
+ invocation_id text,
76
+ fields jsonb NOT NULL DEFAULT '{}'::jsonb,
77
+ truncated boolean NOT NULL DEFAULT false,
78
+ masked_count integer NOT NULL DEFAULT 0,
79
+ subject_id text
80
+ );
81
+
82
+ -- TWO indexes, and the argument for stopping there is as load-bearing as the
83
+ -- argument for these two. Every index is paid for on EVERY insert, and this is
84
+ -- the highest-insert table the box has.
85
+ --
86
+ -- (run_id, ts) — the run-scoped read: "this run's lines, oldest first".
87
+ -- (ts) — the retention sweep's own scan, and the global tail's paging.
88
+ --
89
+ -- NOT indexed, on purpose:
90
+ -- level — six distinct values over millions of rows. The planner would
91
+ -- ignore it for anything but `fatal`, and a scan is cheaper.
92
+ -- message — no trigram index. Substring search is a BOUNDED scan (the reader
93
+ -- always carries a time window), and a GIN trigram index over a
94
+ -- month of log text costs a large fraction of the table it
95
+ -- indexes — on the customer's own disk, for a search nobody runs
96
+ -- on a cold box. The search slice that needs it can add it.
97
+ -- ts (BRIN) — a BRIN index wins on an append-only table whose physical order
98
+ -- matches its timestamps. This table is bulk-DELETEd hourly and
99
+ -- its freed space is reused out of order, so the correlation the
100
+ -- BRIN depends on decays exactly as the table ages.
101
+ CREATE INDEX IF NOT EXISTS logs_run_id_ts_idx ON stackbone_platform.logs (run_id, ts);
102
+ CREATE INDEX IF NOT EXISTS logs_ts_idx ON stackbone_platform.logs (ts);
103
+
104
+ -- PER-TABLE AUTOVACUUM. The cluster default waits for dead tuples to reach 20% of
105
+ -- the table before it even considers a vacuum. This is the one table the box
106
+ -- bulk-deletes on a schedule, so on the defaults an hourly retention pass would
107
+ -- leave its dead rows in place for hours, the heap would keep growing past what
108
+ -- the operator's budget setting measures, and the visibility map would go stale
109
+ -- under the very index the retention scan uses.
110
+ --
111
+ -- 1% plus a floor of 5 000 rows: the fraction keeps a big table vacuumed
112
+ -- promptly, and the explicit threshold stops a nearly-empty table from vacuuming
113
+ -- itself on every few writes.
114
+ ALTER TABLE stackbone_platform.logs SET (
115
+ autovacuum_vacuum_scale_factor = 0.01,
116
+ autovacuum_vacuum_threshold = 5000
117
+ );
118
+
119
+ -- The read-only DB-Explorer role may not read log lines. Same reasoning as
120
+ -- 0011_secrets_revoke_viewer.sql, and the same mechanism: 0005's blanket
121
+ -- `ALTER DEFAULT PRIVILEGES … GRANT SELECT` already fires for every table created
122
+ -- after it, so a table-level REVOKE is the only thing that strips the grant this
123
+ -- CREATE just received.
124
+ --
125
+ -- Logs are the widest surface a credential can escape through. They are scrubbed
126
+ -- on the way in (`log-scrubber.ts`) with both credential SHAPES and the box's own
127
+ -- secret VALUES, but scrubbing is a best effort over free text and this is not:
128
+ -- a `viewer` seat is read-only precisely because it is handed out widely.
129
+ REVOKE ALL ON stackbone_platform.logs FROM stackbone_viewer;
130
+
131
+ -- NO TRIGGERS. Every other table in this schema that Studio watches carries a row
132
+ -- trigger emitting `pg_notify` on the platform channel. Not this one: the box
133
+ -- logs its own boot, its own recurring jobs and every line of every run through
134
+ -- the same pipe, so a per-row NOTIFY here would flood a channel the bridge shares
135
+ -- with the run/step/session frames it exists to deliver — and a NOTIFY payload is
136
+ -- capped at 8 000 bytes, which a log line can exceed on its own. The live tail is
137
+ -- fed from the pipe IN MEMORY, before the row is ever written.
@@ -0,0 +1,43 @@
1
+ -- 0052_telemetry_export_state.sql — what the OTLP export has LOST (PLA-744).
2
+ --
3
+ -- Numbered 0052 against `main`, whose last migration is 0051. The runner keys
4
+ -- `schema_migrations` on the four-digit prefix alone, so two branches that both
5
+ -- take the next free number half-apply and then report a checksum mismatch that
6
+ -- points at the wrong problem. `scripts/check-migration-numbers.mjs` is the
7
+ -- pre-merge gate; renumber against the tip you merge into, not the one you
8
+ -- branched from.
9
+ --
10
+ -- WHY A TABLE AND NOT A COUNTER IN MEMORY. The export is fail-open: a collector
11
+ -- that refuses the connection costs the box its telemetry and nothing else, and
12
+ -- the run goes on. That is the right trade and it is also how an integration
13
+ -- goes quietly dead — a box that has exported nothing since a certificate
14
+ -- expired looks exactly like a box nobody sends much traffic to. The count is
15
+ -- the only thing that tells them apart, and it is worth nothing if it resets
16
+ -- every time the container restarts, which is precisely when an operator looks.
17
+ --
18
+ -- ONE ROW, like `runtime_identity` (0033) and `runtime_control_plane_identity`
19
+ -- (0046): this describes THE runtime's export, not a collection of anything.
20
+ -- The writer upserts it, so the row's absence simply means nothing has been lost
21
+ -- yet, and the read defaults to zero.
22
+ --
23
+ -- failure_count — records that never reached the collector, over this box's
24
+ -- life: a batch a 4xx refused, a batch whose connection was
25
+ -- refused, the records a `partialSuccess` rejected, and the
26
+ -- oldest records the bounded queue dropped. RECORDS, not
27
+ -- batches — "we lost 40 000 lines" and "we lost 80 sends"
28
+ -- are different sentences and only the first is actionable.
29
+ -- `bigint` because a box exporting into a black hole for a
30
+ -- week passes 2^31.
31
+ -- last_failure_at — when the counter last moved. A large count that stopped
32
+ -- moving yesterday is a fixed outage; the same count still
33
+ -- moving is an open one.
34
+ --
35
+ -- NO ENDPOINT COLUMN. Where the box exports to is deployment configuration read
36
+ -- from `OTEL_EXPORTER_OTLP_ENDPOINT`, never a stored setting: a row Studio could
37
+ -- write would let a seat redirect the operator's telemetry. The settings screen
38
+ -- reads the endpoint's presence from the environment and the count from here.
39
+ CREATE TABLE IF NOT EXISTS stackbone_platform.telemetry_export_state (
40
+ id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
41
+ failure_count bigint NOT NULL DEFAULT 0,
42
+ last_failure_at timestamptz
43
+ );
@@ -0,0 +1,51 @@
1
+ -- 0053_runs_exported_at.sql — HOW FAR the OTLP trace export has got on each run
2
+ -- (PLA-746).
3
+ --
4
+ -- Numbered 0053 against `main`, whose last migration is 0050; this branch adds
5
+ -- 0051 and 0052 ahead of it. The runner keys `schema_migrations` on the
6
+ -- four-digit prefix alone, so two branches that both take the next free number
7
+ -- half-apply and then report a checksum mismatch pointing at the wrong problem.
8
+ -- `scripts/check-migration-numbers.mjs` is the pre-merge gate; renumber against
9
+ -- the tip you merge into, not the one you branched from.
10
+ --
11
+ -- WHY A COLUMN AND NOT A QUEUE. `run.finished` (0001's row trigger) travels over
12
+ -- LISTEN/NOTIFY, which reaches a connected listener and nobody else. A deploy
13
+ -- that recycles the container loses the trace of every run closing around the
14
+ -- restart, and a box that dies mid-turn loses it forever. This column is the one
15
+ -- durable fact the sweep needs: the unexported rows ARE the retry queue, so a
16
+ -- second queue would only be a second thing to lose.
17
+ --
18
+ -- A COMPARABLE WATERMARK, NOT A FLAG. `exported_at` holds the `finished_at`
19
+ -- whose trace actually left the box, so the sweep asks
20
+ --
21
+ -- finished_at IS NOT NULL AND (exported_at IS NULL OR finished_at > exported_at)
22
+ --
23
+ -- and not "exported_at IS NULL". A run CAN close twice: the chat reconciler marks
24
+ -- a dead `running` turn `failed`, and the live turn it raced then lands its own
25
+ -- `complete()` — an UPDATE with no status guard, over a row whose `finished_at`
26
+ -- is already set, which fires NO second `run.finished` (the trigger only notifies
27
+ -- on the NULL → NOT NULL transition). Under a nullity check the operator's APM
28
+ -- would say `failed` forever while Studio says done. Equality is the "already
29
+ -- shipped" case and must NOT re-export, which is why the writer stamps the
30
+ -- observed `finished_at` rather than `now()`: a value taken from the clock could
31
+ -- land ahead of a close that happened while the trace was in flight, and that
32
+ -- close would then never be exported at all.
33
+ --
34
+ -- NULLABLE, NO DEFAULT, NO BACKFILL. A NULL means "never exported", and on the
35
+ -- first boot with OTLP enabled that is every run the box has ever recorded. The
36
+ -- sweep does NOT send them: it cuts by the operator's log-retention window, so
37
+ -- turning the export on is not an accidental replay of the box's whole history.
38
+ -- A historical backfill is an explicit operator action, never a side effect.
39
+ ALTER TABLE stackbone_platform.runs
40
+ ADD COLUMN IF NOT EXISTS exported_at timestamptz;
41
+
42
+ -- The sweep's only query, as an index. PARTIAL on the same predicate the sweep
43
+ -- uses, so a box whose export is keeping up scans an index holding almost
44
+ -- nothing — the steady state is "every finished run is exported", and an index
45
+ -- over the whole table would grow with the journal forever. The predicate names
46
+ -- only columns (no `now()`), which is what makes it legal in an index at all;
47
+ -- the age cutoffs are applied on top of it, at read time.
48
+ CREATE INDEX IF NOT EXISTS runs_export_pending_idx
49
+ ON stackbone_platform.runs (finished_at)
50
+ WHERE finished_at IS NOT NULL
51
+ AND (exported_at IS NULL OR finished_at > exported_at);
@@ -0,0 +1,35 @@
1
+ -- 0054_runs_trace_parent.sql — the INBOUND trace a run continues (PLA-747).
2
+ --
3
+ -- Numbered 0054 against `main`, whose last migration is 0050; this branch adds
4
+ -- 0051, 0052 and 0053 ahead of it. The runner keys `schema_migrations` on the
5
+ -- four-digit prefix alone, so two branches that both take the next free number
6
+ -- half-apply and then report a checksum mismatch pointing at the wrong problem.
7
+ -- `scripts/check-migration-numbers.mjs` (PLA-735) is the pre-merge gate;
8
+ -- renumber against the tip you merge into, not the one you branched from.
9
+ --
10
+ -- NOT `trace_id`. The column beside this one holds the WORLD runId — the box's
11
+ -- own join key between a projected `runs` row and the workflow engine's run.
12
+ -- `trace_parent` is the opposite direction: the W3C `traceparent` header a
13
+ -- CALLER sent, naming a trace that was opened outside this box entirely. A
14
+ -- reader who conflates the two searches the wrong id in the wrong system, so the
15
+ -- names are deliberately not variations of each other.
16
+ --
17
+ -- HONOURED MEANS STORED. The header arrives on one HTTP request — `POST
18
+ -- /api/workflows/:name/start`, the only door that can carry it today (the
19
+ -- connector intake is polling and there is no inbound webhook receiver) — and
20
+ -- the spans that need it leave minutes later, from a sweep with no request in
21
+ -- scope. Memory would lose it on the next deploy; the row is the only place it
22
+ -- survives to the export.
23
+ --
24
+ -- NULLABLE, NO DEFAULT, NO BACKFILL, NO CHECK. A NULL means "nobody sent one",
25
+ -- which is what almost every run is. No CHECK for the same reason 0037 has none:
26
+ -- the grammar is validated in the box before the write, and an older box must be
27
+ -- able to STORE a value a newer build validated by a rule this one does not know.
28
+ -- The read side re-parses it anyway (`otlp/traceparent.ts`), so a value that
29
+ -- somehow got in malformed is ignored at export rather than breaking it.
30
+ --
31
+ -- NO INDEX. Nothing searches by it: every read is by the run's own primary key —
32
+ -- the trace exporter reads the run it is exporting, the log exporter reads the
33
+ -- run a log line names. An index would be a write cost with no reader.
34
+ ALTER TABLE stackbone_platform.runs
35
+ ADD COLUMN IF NOT EXISTS trace_parent text;
@@ -0,0 +1,105 @@
1
+ -- 0055_audit_log.sql — the BOX's half of the organization activity log
2
+ -- (PLA-898). One row per human mutation the box itself carried out: an approval
3
+ -- decided, the creator's config saved or rolled back, a workspace setting
4
+ -- changed.
5
+ --
6
+ -- Numbered 0055 against `next`, whose last migration is 0054. The runner keys
7
+ -- `schema_migrations` on the four-digit prefix alone, so two branches that both
8
+ -- take the next free number half-apply and then report a checksum mismatch
9
+ -- pointing at the wrong problem. `scripts/check-migration-numbers.mjs` is the
10
+ -- pre-merge gate; renumber against the tip you merge into.
11
+ --
12
+ -- THE SHAPE IS NOT THIS FILE'S TO CHOOSE. Nine of the ten columns below are the
13
+ -- shared ones `auditColumns()` (`@stackbone/audit-core`) builds, the same set the
14
+ -- control plane's `public.audit_log` is grown from, so the federated reader that
15
+ -- merges the two timelines never has to translate between dialects.
16
+ -- `libs/shared/box-schema/src/lib/schema/audit-log.spec.ts` is what holds the
17
+ -- Drizzle definition, this DDL and that builder to one shape.
18
+ --
19
+ -- What only the box adds is `agent_id`: everything a box records happened while
20
+ -- it was serving one agent, and the merged timeline's per-agent filter is matched
21
+ -- on it. The control plane's table has no such column — nothing it records
22
+ -- belongs to an agent at all.
23
+ --
24
+ -- NO FOREIGN KEYS. `organization_id` and `actor_user_id` are the control plane's
25
+ -- ids, and neither an `organization` nor a `user` table exists inside a box, so
26
+ -- there is nothing here for them to reference. `actor_user_id` is NULLABLE, and
27
+ -- that is the point rather than a concession: a `stackbone dev` save happens with
28
+ -- nobody signed in, and the honest row for it names no actor instead of failing
29
+ -- the save or borrowing somebody's name. `organization_id` stays NOT NULL — a box
30
+ -- nobody has registered records the nil uuid, which says "this box answers for no
31
+ -- organization yet" and never claims a tenant it does not have.
32
+ CREATE TABLE IF NOT EXISTS stackbone_platform.audit_log (
33
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
34
+ organization_id uuid NOT NULL,
35
+ actor_user_id uuid,
36
+ agent_id text,
37
+ action text NOT NULL,
38
+ subject_type text NOT NULL,
39
+ subject_id uuid,
40
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
41
+ metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
42
+ created_at timestamptz NOT NULL DEFAULT now()
43
+ );
44
+
45
+ -- The four indexes, three of them declared by `auditIndexes()` so both planes
46
+ -- carry the same set under the same names. A name is unique per SCHEMA in
47
+ -- Postgres — the control plane's table is in `public` and this one in
48
+ -- `stackbone_platform` — so the repeated names read as "the same index" rather
49
+ -- than collide. Twenty-four months of human actions is a small table, but the
50
+ -- filters the contract promises should not start life as sequential scans.
51
+ CREATE INDEX IF NOT EXISTS audit_log_organization_idx
52
+ ON stackbone_platform.audit_log (organization_id, created_at);
53
+ --> statement-breakpoint
54
+ CREATE INDEX IF NOT EXISTS audit_log_subject_idx
55
+ ON stackbone_platform.audit_log (subject_type, subject_id);
56
+ --> statement-breakpoint
57
+ CREATE INDEX IF NOT EXISTS audit_log_actor_idx
58
+ ON stackbone_platform.audit_log (organization_id, actor_user_id, created_at);
59
+ --> statement-breakpoint
60
+ -- The box's own: "everything this agent did", newest first.
61
+ CREATE INDEX IF NOT EXISTS audit_log_agent_idx
62
+ ON stackbone_platform.audit_log (agent_id, created_at);
63
+ --> statement-breakpoint
64
+
65
+ -- THE TRAIL IS APPEND-ONLY, AND THE DATABASE IS WHAT SAYS SO.
66
+ --
67
+ -- The table has exactly one writer (the box audit recorder, inside the
68
+ -- transaction the mutation it describes already holds) and no updater or deleter
69
+ -- at all. That is a property of today's code, not a guarantee: any future
70
+ -- handler, migration or psql session inside the customer's own infrastructure
71
+ -- could rewrite history, and a trail that can be edited by whoever is being
72
+ -- audited is not evidence. So the rule lives here, below every application, where
73
+ -- no code path can forget it.
74
+ --
75
+ -- UNCONDITIONAL, unlike the control plane's twin (`db-core`'s 0023), which has to
76
+ -- let one DELETE through: there, `audit_log.organization_id` is ON DELETE CASCADE
77
+ -- and erasing an organization would otherwise be refused by its own children.
78
+ -- Nothing here carries a foreign key, so there is no cascade to admit and no
79
+ -- carve-out to write. Adding one "for parity" would open the only hole this
80
+ -- trigger exists to close.
81
+ --
82
+ -- No retention exception. The age-window purge, when it arrives, amends THIS
83
+ -- function (`CREATE OR REPLACE FUNCTION`) rather than adding a second, competing
84
+ -- guard. Nothing here is a partial version of it: there is no age test and no
85
+ -- column test to mistake for one.
86
+ --
87
+ -- TRUNCATE is NOT covered, and cannot be by a row trigger — it bypasses them
88
+ -- entirely. Blocking it needs a BEFORE TRUNCATE statement trigger or, better,
89
+ -- revoking the privilege from the application role, and it would break the test
90
+ -- fixtures that reset the schema with `TRUNCATE ... CASCADE`. Out of scope here.
91
+ CREATE OR REPLACE FUNCTION stackbone_platform.audit_log_refuse_mutation() RETURNS trigger
92
+ LANGUAGE plpgsql AS $fn$
93
+ BEGIN
94
+ RAISE EXCEPTION 'audit_log rows are immutable — % refused', TG_OP
95
+ USING ERRCODE = 'restrict_violation';
96
+ END;
97
+ $fn$;
98
+ --> statement-breakpoint
99
+ -- One trigger over both verbs so there is a single object to find and amend.
100
+ -- BEFORE, so the refusal happens before any row is touched.
101
+ DROP TRIGGER IF EXISTS audit_log_refuse_mutation_bud ON stackbone_platform.audit_log;
102
+ --> statement-breakpoint
103
+ CREATE TRIGGER audit_log_refuse_mutation_bud
104
+ BEFORE UPDATE OR DELETE ON stackbone_platform.audit_log
105
+ FOR EACH ROW EXECUTE FUNCTION stackbone_platform.audit_log_refuse_mutation();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbone/cli",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,6 +18,8 @@
18
18
  "@bufbuild/protobuf": "^2.12.0",
19
19
  "@clack/prompts": "^1.2.0",
20
20
  "@hono/node-server": "^2.0.0",
21
+ "@hono/zod-openapi": "^1.6.1",
22
+ "@scalar/hono-api-reference": "^0.11.16",
21
23
  "ajv": "^8.20.0",
22
24
  "ajv-formats": "^3.0.1",
23
25
  "bullmq": "^5.76.6",