@stackbone/cli 0.2.8 → 0.3.0

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,39 @@
1
+ -- 0028_browser_provider_system_secrets.sql — adopt the browser-provider
2
+ -- credentials an operator created BY HAND (STA-288 / STA-290).
3
+ --
4
+ -- WHY THESE ROWS EXIST AT ALL: until now the documented way to point the browser
5
+ -- tooling at the managed remote browser was to type `BROWSERBASE_API_KEY` and
6
+ -- `BROWSERBASE_PROJECT_ID` straight into the Studio → Secrets screen (see the SDK's
7
+ -- browser-tools reference). Those rows landed the only way that screen can write
8
+ -- them: `is_system = false`. Live boxes therefore carry hand-made, non-system rows
9
+ -- under names that are now RESERVED (`RESERVED_BROWSER_PROVIDER_SECRET_NAMES` in
10
+ -- libs/shared/validators/src/lib/studio/studio-secrets.ts).
11
+ --
12
+ -- WHY RESERVING ALONE IS NOT ENOUGH: reservation blocks creating, overwriting and
13
+ -- deleting a name through the Secrets surface. It does not touch what is already
14
+ -- stored. Without this migration the operator would be left staring at a listed row
15
+ -- they can neither edit nor delete — a dead end. Flipping the flag is what hides the
16
+ -- row from the listing (`listSecrets` filters `is_system = false`), blocks revealing
17
+ -- it, and hands custody to the privileged browser-provider route.
18
+ --
19
+ -- The names are duplicated here on purpose: SQL cannot import the TypeScript
20
+ -- constant, and platform-schema deliberately depends on no other lib. The
21
+ -- browser-provider mode key (`BROWSER_MODE`) is listed too even though nothing wrote
22
+ -- it before this feature — a box cannot have one, and including it keeps the SQL and
23
+ -- the registry readable as the same list. `STACKBONE_BROWSER_CDP_URL` is absent
24
+ -- because the `STACKBONE_` prefix has always been reserved, so a hand-made row under
25
+ -- that name never existed.
26
+ --
27
+ -- ONLY the flag moves — `nonce` and `ciphertext` are untouched, so the value keeps
28
+ -- decrypting with the box's per-agent key and the browser tooling (which reads
29
+ -- through `client.secrets`, a facade that deliberately does not filter system rows)
30
+ -- keeps working across the flip.
31
+ --
32
+ -- IDEMPOTENT: the `is_system = false` guard makes a second run affect zero rows and
33
+ -- stops it churning `updated_at` on rows already adopted. On a box that never had
34
+ -- these secrets the statement is a plain no-op.
35
+ UPDATE stackbone_platform.secrets
36
+ SET is_system = true,
37
+ updated_at = now()
38
+ WHERE name IN ('BROWSER_MODE', 'BROWSERBASE_API_KEY', 'BROWSERBASE_PROJECT_ID')
39
+ AND is_system = false;
@@ -0,0 +1,37 @@
1
+ -- 0029_workspace_settings.sql — the PLATFORM-owned operational settings of a
2
+ -- workspace (STA-311). One row, one JSON payload, plus the author and the
3
+ -- timestamp of the last change.
4
+ --
5
+ -- Why a table of its own instead of a key inside `agent_config` (0010): that
6
+ -- payload is validated against the JSON Schema the build extracts from the
7
+ -- creator's `config.schema.ts`, and `z.toJSONSchema` emits a CLOSED object by
8
+ -- default (`additionalProperties: false`). The moment a creator declares a config
9
+ -- schema, a platform key living inside their payload would be rejected outright.
10
+ -- These knobs belong to the platform, not to the creator, so they get their own
11
+ -- store with a schema the platform owns.
12
+ --
13
+ -- Why no version history (unlike `agent_config_versions`, 0007): these are
14
+ -- operational settings, not a creator artifact. Last-write-wins with an author
15
+ -- stamp is enough — there is nothing to roll back to.
16
+ --
17
+ -- The payload defaults to `{}`; the DEFAULTS live in the platform schema
18
+ -- (`@stackbone/validators` → `studio-workspace-settings.ts`), never in the DDL,
19
+ -- so the runtime, the SDK and the Studio screen all read one source of truth.
20
+
21
+ -- `updated_at` is NULLABLE and carries NO default on purpose: the seed row below
22
+ -- would otherwise stamp every fresh box with the migration's wall clock, and
23
+ -- "nobody ever configured this workspace" would be indistinguishable from a real
24
+ -- edit. NULL means untouched; a write always sets it explicitly.
25
+ CREATE TABLE IF NOT EXISTS stackbone_platform.workspace_settings (
26
+ id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
27
+ payload jsonb NOT NULL DEFAULT '{}'::jsonb,
28
+ updated_at timestamptz,
29
+ updated_by text
30
+ );
31
+
32
+ -- Seed the single row so readers never have to branch on "no row yet".
33
+ -- Idempotent: re-running the migration leaves an already-written payload (and
34
+ -- its author stamp) untouched.
35
+ INSERT INTO stackbone_platform.workspace_settings (id, payload)
36
+ VALUES (1, '{}'::jsonb)
37
+ ON CONFLICT (id) DO NOTHING;
@@ -0,0 +1,27 @@
1
+ -- 0030_run_steps_connection_hook.sql — widen the run_steps type CHECK to admit
2
+ -- two new activity step kinds alongside the existing `workflow_step` /
3
+ -- `model_call` rows (0018_run_steps_v2.sql).
4
+ --
5
+ -- `connection` — one row per outbound connector call (`callConnector(...)` /
6
+ -- `connection(id).op(...)`), instrumented in the runtime the same way a direct
7
+ -- `client.ai` call records a `model_call`. Carries the call's input
8
+ -- (connector/operation/args), output and error; no token columns.
9
+ --
10
+ -- `hook` — one row per `requestApproval(...)` durable pause (the WDK hook
11
+ -- that backs HITL). Projected running when the approval is created and resolved
12
+ -- to completed/failed when a human decides or the wait times out. The
13
+ -- `approvals` row stays the source of truth for the inbox; this row only mirrors
14
+ -- the pause onto the run-step timeline so it reads inline in the trace viewer.
15
+ --
16
+ -- The pg_notify trigger (`notify_run_step_event`) already streams ANY type over
17
+ -- SSE, so no trigger change is needed — new-typed rows flow live for free.
18
+
19
+ -- The 0018 inline column CHECK is auto-named `run_steps_type_check` by Postgres.
20
+ -- Drop it (guarded) and recreate it widened as a named constraint so future
21
+ -- migrations can target it by a stable name.
22
+ ALTER TABLE stackbone_platform.run_steps
23
+ DROP CONSTRAINT IF EXISTS run_steps_type_check;
24
+
25
+ ALTER TABLE stackbone_platform.run_steps
26
+ ADD CONSTRAINT run_steps_type_check
27
+ CHECK (type IN ('workflow_step', 'model_call', 'connection', 'hook'));
@@ -0,0 +1,51 @@
1
+ -- 0031_recurring_job_executions.sql — the box's record of WHEN each RECURRING
2
+ -- job last ran, and how it ended (STA-343).
3
+ --
4
+ -- Why a table at all: BullMQ cannot answer "when did this repeatable last run?".
5
+ -- Every recurring job the runtime declares drops its completed ticks by design
6
+ -- (`removeOnComplete: true` in `DEFAULT_RECURRING_JOB_OPTS`), so the finished tick
7
+ -- is gone from Redis the moment it succeeds. The recurring-jobs REGISTRY writes
8
+ -- this row itself, from the wrapper it already puts around every tick handler, so
9
+ -- a job author gets the record for free by declaring an entry.
10
+ --
11
+ -- Writing the PREVIOUS occurrence down also removes work downstream: the
12
+ -- countdown a screen shows is `(now - previous) / (next - previous)`, and BullMQ
13
+ -- only hands out `next` — deriving `previous` from a cron pattern is real work we
14
+ -- would rather not do when the tick already knew the answer.
15
+ --
16
+ -- job_id — the registry's scheduler id (e.g. `decl:digest#0`,
17
+ -- `sub:<link-id>`). PRIMARY KEY: ONE row per job, overwritten on
18
+ -- each tick, so the table is bounded by the number of DECLARED
19
+ -- jobs and never grows with the number of ticks. This is a "last
20
+ -- execution" record, not a history.
21
+ -- family — the scheduler-id prefix that namespaces the job (`decl:`,
22
+ -- `dyn:`, `sub:`, `connector-token-refresh:`). Kept even though
23
+ -- the id starts with it: a reader groups by family, and an id
24
+ -- like `sub:<uuid>` is not self-describing without the registry's
25
+ -- own declaration.
26
+ -- started_at — measured by the wrapper BEFORE the handler runs.
27
+ -- finished_at — measured by the wrapper when the handler settles. Both come
28
+ -- from the BOX's clock (never `now()` here), so a reader can
29
+ -- compare them against BullMQ's `next` without mixing clocks.
30
+ -- status — terminal outcome of the tick: `done` or `failed`. Never
31
+ -- in-flight: the row is written ONCE, when the tick settles, so
32
+ -- a box that dies mid-tick leaves no half-written row.
33
+ -- error — the failure reason on `failed`; NULL on `done`. Always
34
+ -- rewritten (never merged), so a green row can't carry the stale
35
+ -- reason of the failure before it.
36
+ --
37
+ -- NOT seeded: a job that has never run must report NO last execution rather than
38
+ -- a fabricated one, so nothing here defaults to `now()` and no row is planted for
39
+ -- a declared-but-never-run job.
40
+ --
41
+ -- Idempotent (CREATE TABLE IF NOT EXISTS): every box re-applies the migration set
42
+ -- on boot, and an existing box must keep everything it already recorded.
43
+
44
+ CREATE TABLE IF NOT EXISTS stackbone_platform.recurring_job_executions (
45
+ job_id text PRIMARY KEY,
46
+ family text NOT NULL,
47
+ started_at timestamptz NOT NULL,
48
+ finished_at timestamptz NOT NULL,
49
+ status text NOT NULL CHECK (status IN ('done', 'failed')),
50
+ error text
51
+ );
@@ -0,0 +1,48 @@
1
+ -- 0032_run_steps_external_step_id.sql — turn `run_steps` into the journal of ANY
2
+ -- execution, not just a workflow's. Two changes, one migration:
3
+ --
4
+ -- 1. RENAME the correlation key. `world_step_id` (0018_run_steps_v2.sql) was
5
+ -- named after its only producer at the time: the workflow projector storing
6
+ -- the World `stepId`. A deep-agent chat turn now writes its `tool_call_id`
7
+ -- into the same slot, so the column becomes `external_step_id` — "the
8
+ -- producer's own correlation key", whoever the producer is. Persisting it is
9
+ -- not cosmetic: a tool that paused for human approval resumes in ANOTHER
10
+ -- process, which has no row id in memory and can only find the open step by
11
+ -- this key.
12
+ --
13
+ -- Postgres stores an index's predicate and columns by attribute NUMBER, so
14
+ -- renaming the column rewrites `(run_id, external_step_id) WHERE
15
+ -- external_step_id IS NOT NULL` automatically — the partial unique index
16
+ -- keeps biting with no rebuild. Only its NAME has to be fixed by hand, so it
17
+ -- stops lying too.
18
+ --
19
+ -- 2. WIDEN the type CHECK with two kinds the chat journal needs:
20
+ -- `tool_call` — one tool invocation inside a turn.
21
+ -- `agent` — one subagent delegation (the `task` tool call and the
22
+ -- subagent boundary folded into ONE row; the tools it calls
23
+ -- hang off it via parent_step_id).
24
+ -- The four existing types are reused verbatim. 0030 already made
25
+ -- `run_steps_type_check` a NAMED constraint precisely so a later migration
26
+ -- could target it by name — reuse it.
27
+ --
28
+ -- The pg_notify trigger (`notify_run_step_event`) streams ANY type and reads the
29
+ -- correlation column by name nowhere, so no trigger change is needed — rows of
30
+ -- the new types flow live over SSE for free.
31
+ --
32
+ -- Nothing writes `tool_call` / `agent` rows yet: this migration only opens the
33
+ -- door for the instrumentation that follows.
34
+
35
+ ALTER TABLE stackbone_platform.run_steps
36
+ RENAME COLUMN world_step_id TO external_step_id;
37
+
38
+ ALTER INDEX IF EXISTS stackbone_platform.run_steps_world_step_uq
39
+ RENAME TO run_steps_external_step_uq;
40
+
41
+ ALTER TABLE stackbone_platform.run_steps
42
+ DROP CONSTRAINT IF EXISTS run_steps_type_check;
43
+
44
+ ALTER TABLE stackbone_platform.run_steps
45
+ ADD CONSTRAINT run_steps_type_check
46
+ CHECK (type IN (
47
+ 'workflow_step', 'model_call', 'connection', 'hook', 'tool_call', 'agent'
48
+ ));
@@ -0,0 +1,25 @@
1
+ -- 0032_runtime_identity.sql — the identity a runtime container derives for
2
+ -- itself on first boot (STA-350).
3
+ --
4
+ -- The image used to demand an installation id and an HMAC secret as environment
5
+ -- variables. Neither is real configuration: the installation id only has to stay
6
+ -- stable across restarts of this container, and the HMAC secret goes unused
7
+ -- until the operator links the deployment to a control plane. Making the
8
+ -- customer invent both before the box will start was pure friction, so the box
9
+ -- now mints them once and remembers them here.
10
+ --
11
+ -- One row, like `workspace_settings` (0029): this table describes THE runtime,
12
+ -- not a collection of anything. The row IS the identity, so there is no seed —
13
+ -- its absence is exactly what "this database has never hosted a runtime" means,
14
+ -- and that absence is the signal the boot warns about before minting a fresh one.
15
+ --
16
+ -- Deliberately NOT encrypted with `STACKBONE_SECRET_KEY`: an operator debugging a
17
+ -- link failure has to be able to read the signing secret out of their own
18
+ -- database, and this database already holds every encrypted secret plus every
19
+ -- run, so storing one more value in it adds no new exposure.
20
+ CREATE TABLE IF NOT EXISTS stackbone_platform.runtime_identity (
21
+ id int PRIMARY KEY DEFAULT 1 CHECK (id = 1),
22
+ installation_id uuid NOT NULL,
23
+ hmac_secret text NOT NULL,
24
+ created_at timestamptz NOT NULL DEFAULT now()
25
+ );
@@ -0,0 +1,92 @@
1
+ -- 0034_guardrails.sql — the box's own GUARDRAIL policies (STA-359): the rules an
2
+ -- operator configures to stop a turn before it ever reaches the agent.
3
+ --
4
+ -- Numbered 0034: slots 0032 and 0033 were taken by the trunk while this feature
5
+ -- was being built (`0032_run_steps_external_step_id`, `0033_runtime_identity`).
6
+ -- The runner keys `schema_migrations` by the 4-digit prefix, so two files sharing
7
+ -- a number are not merely untidy — the second one to sort is read as a rewrite of
8
+ -- a version already applied and aborts the whole boot. Renumber against the tip
9
+ -- you are actually merging into, not the one you branched from.
10
+ --
11
+ -- Why the policy lives in the DATABASE and not in the agent bundle: a guardrail
12
+ -- is an OPERATOR knob, not a creator artifact. It has to be editable from Studio
13
+ -- and take effect on the NEXT turn, with no rebuild and no restart — which it
14
+ -- cannot do if it is baked into a compiled graph. The runtime therefore reads the
15
+ -- effective policy per turn (behind a short cache), straight off these rows.
16
+ --
17
+ -- guardrail_definitions — one row per named rule.
18
+ -- name — operator-facing, UNIQUE per box: it is what a blocked user
19
+ -- reads in the reason, so two rules may not share it.
20
+ -- check_type — which catalog check runs (`denylist`, `length_cap`). The
21
+ -- catalog itself lives in `@stackbone/guardrails` and is served
22
+ -- over HTTP; this column is deliberately NOT constrained by a
23
+ -- CHECK, because a box running an older build must be able to
24
+ -- STORE a check type it cannot run rather than reject the write
25
+ -- and lose the operator's policy. An unknown type fails CLOSED
26
+ -- at evaluation time (the engine's own rule), which is the safe
27
+ -- place for that decision.
28
+ -- target — which side of the turn it inspects (`input` today).
29
+ -- action — what it does when it fires (`block` today).
30
+ -- params — the check's own settings, validated against the CATALOG at the
31
+ -- handler, never by the DDL. The catalog is the one description
32
+ -- of each check's shape; a CHECK constraint here would be a
33
+ -- second one that drifts.
34
+ -- enabled — a disabled rule NEVER fires. Kept as a flag rather than a
35
+ -- delete so an operator can switch a policy off for one incident
36
+ -- and back on afterwards without retyping it.
37
+ -- fail_open — what to do when the CHECK ITSELF cannot run (bad params, an
38
+ -- invalid pattern). FALSE = fail closed, the safe default for a
39
+ -- guard. Never confused with a real policy violation, which
40
+ -- blocks either way.
41
+ --
42
+ -- guardrail_attachments — which units a rule applies to. Created now, exercised
43
+ -- now only in its workspace-wide shape (`scope = 'workspace'`, `unit_id`
44
+ -- NULL), because the workspace-wide default is IMPLICIT in this slice: every
45
+ -- enabled definition applies. The per-unit resolution (a rule attached to one
46
+ -- agent or one workflow) is a later slice; the table ships now so the
47
+ -- resolver's contract — de-duplication, deterministic order, dangling rows
48
+ -- ignored — is real behaviour with tests rather than a promise.
49
+ -- Deleting a definition CASCADES its attachments: an attachment to a rule
50
+ -- that no longer exists is not a policy, it is a leak.
51
+ --
52
+ -- No defaults for the payload live here — the documented defaults live in the
53
+ -- platform schema (`@stackbone/validators` → `studio-guardrails.ts`), so the
54
+ -- runtime, the facade and the Studio form all read one source of truth. The
55
+ -- column defaults below are structural only (identity, timestamps, and the two
56
+ -- booleans whose absence would otherwise make a row unreadable).
57
+
58
+ CREATE TABLE IF NOT EXISTS stackbone_platform.guardrail_definitions (
59
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
60
+ name text NOT NULL,
61
+ check_type text NOT NULL,
62
+ target text NOT NULL,
63
+ action text NOT NULL,
64
+ params jsonb NOT NULL DEFAULT '{}'::jsonb,
65
+ enabled boolean NOT NULL DEFAULT true,
66
+ fail_open boolean NOT NULL DEFAULT false,
67
+ created_at timestamptz NOT NULL DEFAULT now(),
68
+ updated_at timestamptz NOT NULL DEFAULT now(),
69
+ -- The acting user, as the verified JWT / signed actor header reported them.
70
+ -- NULL on the un-authenticated `stackbone dev` datapath.
71
+ created_by text,
72
+ updated_by text
73
+ );
74
+
75
+ -- The name is what a blocked user reads. Two rules sharing it would make the
76
+ -- reason ambiguous, so the uniqueness is enforced by the box, not by the screen.
77
+ CREATE UNIQUE INDEX IF NOT EXISTS guardrail_definitions_name_key
78
+ ON stackbone_platform.guardrail_definitions (name);
79
+
80
+ CREATE TABLE IF NOT EXISTS stackbone_platform.guardrail_attachments (
81
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
82
+ definition_id uuid NOT NULL
83
+ REFERENCES stackbone_platform.guardrail_definitions (id) ON DELETE CASCADE,
84
+ -- `workspace` is the only scope in this slice. Widened by the per-unit slice.
85
+ scope text NOT NULL,
86
+ -- The attached unit (an agent name, a workflow name). NULL for `workspace`.
87
+ unit_id text,
88
+ created_at timestamptz NOT NULL DEFAULT now()
89
+ );
90
+
91
+ CREATE INDEX IF NOT EXISTS guardrail_attachments_definition_idx
92
+ ON stackbone_platform.guardrail_attachments (definition_id);
@@ -0,0 +1,41 @@
1
+ -- 0035_run_steps_guardrail.sql — widen the run_steps type CHECK to admit the
2
+ -- GUARDRAIL decision step (STA-359), alongside `workflow_step` / `model_call`
3
+ -- (0018), `connection` / `hook` (0030) and `tool_call` / `agent` (0032, from the
4
+ -- trunk — see the note above the constraint below).
5
+ --
6
+ -- `guardrail` — one row per guardrail decision taken at a border. A blocked chat
7
+ -- turn writes exactly one: the name of the rule that fired, the side of the turn
8
+ -- it inspected, and the reason the user was shown. It exists so a blocked turn is
9
+ -- READABLE in the trace viewer — inline with the model calls and tool calls the
10
+ -- turn never got to make — instead of being an invisible non-event that only
11
+ -- shows up as a suspiciously short conversation.
12
+ --
13
+ -- One spelling, everywhere: `guardrail` is the `run_steps.type` value, the
14
+ -- neutral `AgentEvent` variant and the AG-UI `CUSTOM` frame name. The earlier
15
+ -- placeholder `guardrail_triggered` (docs only, never in code) is retired.
16
+ --
17
+ -- The pg_notify trigger (`notify_run_step_event`) already streams ANY type over
18
+ -- SSE, so no trigger change is needed — guardrail rows flow live for free.
19
+
20
+ -- 0030 recreated this as a NAMED constraint precisely so it could be targeted
21
+ -- here. Drop it (guarded — every box re-applies the whole set on boot) and
22
+ -- recreate it widened.
23
+ --
24
+ -- The list below names `tool_call` and `agent`, which NOTHING IN THIS FEATURE
25
+ -- WRITES: they arrived with `0032_run_steps_external_step_id` and belong to the
26
+ -- deep-agent turn journal. Recreating a named CHECK replaces it WHOLESALE rather
27
+ -- than adding to it, so naming only the types this feature cares about would
28
+ -- revoke those two the moment this file runs after 0032. Nothing would flag it:
29
+ -- the two migrations are different files, so there is no merge conflict, and the
30
+ -- damage would surface much later as a 23514 on the first tool call of every chat
31
+ -- turn. The rule for anyone touching this constraint again: carry the whole
32
+ -- accepted set forward, never just your own additions.
33
+ ALTER TABLE stackbone_platform.run_steps
34
+ DROP CONSTRAINT IF EXISTS run_steps_type_check;
35
+
36
+ ALTER TABLE stackbone_platform.run_steps
37
+ ADD CONSTRAINT run_steps_type_check
38
+ CHECK (type IN (
39
+ 'workflow_step', 'model_call', 'connection', 'hook',
40
+ 'tool_call', 'agent', 'guardrail'
41
+ ));
@@ -0,0 +1,61 @@
1
+ -- 0036_guardrail_attachment_units.sql — attach a guardrail WHERE IT IS NEEDED
2
+ -- (STA-365), instead of every enabled rule applying to everything.
3
+ --
4
+ -- 0034 shipped `guardrail_attachments` already carrying `scope` + `unit_id`, but
5
+ -- only ever wrote the workspace-wide shape, because resolution at the time was
6
+ -- implicit: an enabled definition applied everywhere. This migration turns that
7
+ -- table into the real thing.
8
+ --
9
+ -- `scope` now takes three values:
10
+ --
11
+ -- `workspace` — the DEFAULT SET (`unit_id` NULL). It covers every agent and
12
+ -- every workflow, including ones added after the rule was written. This is
13
+ -- what makes a newly created agent protected with nobody remembering to do
14
+ -- anything.
15
+ -- `agent` / `workflow` — ONE named unit (`unit_id` = its name), so a high-risk
16
+ -- agent can be stricter than the rest without duplicating the policy.
17
+ --
18
+ -- Deliberately NOT a CHECK constraint on `scope`, for the same reason `check_type`
19
+ -- has none: a box running an older build must be able to STORE a scope it cannot
20
+ -- resolve rather than reject the write and lose the operator's policy. An
21
+ -- unrecognised scope is IGNORED at resolution time (the resolver's own rule),
22
+ -- which is the safe place for that decision — a stale or unreadable attachment
23
+ -- must never take a chat turn down.
24
+ --
25
+ -- Two things this file does add:
26
+ --
27
+ -- 1. UNIQUENESS per (rule, place). Attaching the same rule to the same unit twice
28
+ -- would run the same check twice on one turn — the resolver de-duplicates by
29
+ -- definition id anyway, but a duplicate row is a lie about the policy and it
30
+ -- would show up twice in the blast-radius summary the screen paints before an
31
+ -- edit or a delete. `COALESCE(unit_id, '')` is what makes the workspace shape
32
+ -- unique too: plain SQL NULLs are all distinct from each other, so without it
33
+ -- one rule could hold ten identical workspace attachments.
34
+ --
35
+ -- 2. A BACKFILL. Every definition stored under 0034 relied on the implicit
36
+ -- workspace-wide default. Once resolution is attachment-driven, a definition
37
+ -- with no attachment rows applies NOWHERE — so an upgrade that skipped this
38
+ -- step would silently switch off every guardrail an operator had already
39
+ -- configured. Giving each of them the workspace attachment they implicitly had
40
+ -- keeps behaviour identical across the upgrade.
41
+
42
+ -- The unique index must exist before the backfill, so a re-run cannot double the
43
+ -- backfilled rows.
44
+ CREATE UNIQUE INDEX IF NOT EXISTS guardrail_attachments_unit_key
45
+ ON stackbone_platform.guardrail_attachments (definition_id, scope, COALESCE(unit_id, ''));
46
+
47
+ -- Index the lookup the resolver's read actually makes: every attachment for the
48
+ -- box, joined back onto its definition. `definition_id` is already indexed by
49
+ -- 0034; this one serves the per-unit filter a large box would otherwise scan.
50
+ CREATE INDEX IF NOT EXISTS guardrail_attachments_unit_idx
51
+ ON stackbone_platform.guardrail_attachments (scope, unit_id);
52
+
53
+ INSERT INTO stackbone_platform.guardrail_attachments (definition_id, scope, unit_id)
54
+ SELECT d.id, 'workspace', NULL
55
+ FROM stackbone_platform.guardrail_definitions d
56
+ WHERE NOT EXISTS (
57
+ SELECT 1
58
+ FROM stackbone_platform.guardrail_attachments a
59
+ WHERE a.definition_id = d.id
60
+ )
61
+ ON CONFLICT DO NOTHING;
@@ -0,0 +1,45 @@
1
+ -- 0037_runs_entity_kind.sql — record WHICH KIND of entity produced a run, so two
2
+ -- entries that share a name stop contaminating each other (STA-426).
3
+ --
4
+ -- 0020 denormalised the entity NAME onto the run and its comment claimed that
5
+ -- "combined with the existing `trigger` the UI knows the kind". That is not true:
6
+ -- `trigger` records WHY a run started (manual, cron, chat, workflow), not WHAT the
7
+ -- entity is — a workflow run started by cron carries `cron`, and a workflow that
8
+ -- opens an agent turn carries `chat`. So the name was doing identity work on its
9
+ -- own, and a deep agent named `sync` plus a workflow named `sync` landed in one row
10
+ -- set: every screen that filters runs by name showed the two mixed together.
11
+ --
12
+ -- The guardrails side of the codebase already settled this vocabulary — a guardrail
13
+ -- attachment is scoped to an `agent` or a `workflow` unit precisely because the KIND
14
+ -- is part of an entry's identity, never just the name. `entity_kind` brings the runs
15
+ -- table in line with it.
16
+ --
17
+ -- Deliberately NOT a CHECK constraint, for the same reason 0036's `scope` has none:
18
+ -- a box running an older build must be able to STORE a kind it cannot resolve rather
19
+ -- than reject the write and lose the run. An unrecognised kind simply never matches a
20
+ -- filter, which is the safe failure.
21
+ --
22
+ -- NO BACKFILL. Rows written before this migration keep a NULL kind, and the two reads
23
+ -- that filter by kind treat that gap differently, on purpose.
24
+ --
25
+ -- The runs LIST admits a NULL kind under a matching name, so nothing that works today
26
+ -- stops working. Under a name two entries share, such a row shows up on both: nothing
27
+ -- on it says which one it was, and a human reading the list can see that for himself.
28
+ --
29
+ -- The AGGREGATE read admits it only for a name that is unambiguous in the request.
30
+ -- Excluding legacy rows outright reported a workspace that had been running for a week
31
+ -- as idle right after every upgrade, while the list one click away showed them — two
32
+ -- surfaces of one feature contradicting each other. But admitting them under a shared
33
+ -- name would count one run twice and invent activity, so there they stay out of both.
34
+
35
+ ALTER TABLE stackbone_platform.runs
36
+ ADD COLUMN IF NOT EXISTS entity_kind text;
37
+
38
+ -- Backs the (name, kind) exact-match filter and the distinct (name, kind) list that
39
+ -- feeds the Runs screen's dropdown. A NEW index rather than a widened
40
+ -- `runs_entity_name_idx`: dropping and recreating 0020's index under its own name
41
+ -- would silently revoke whatever a later migration or an operator added to it, and
42
+ -- the name-only filter still uses it.
43
+ CREATE INDEX IF NOT EXISTS runs_entity_name_kind_idx
44
+ ON stackbone_platform.runs (entity_name, entity_kind)
45
+ WHERE entity_name IS NOT NULL;