@anchrd/intel-api 0.39.0 → 0.40.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.
@@ -28,6 +28,34 @@ import type { D1Database } from "./db.types.js";
28
28
  * row-value comparison here: "later timestamp, OR same timestamp and larger id".
29
29
  */
30
30
  export declare const auditFeedQuery: string;
31
+ /**
32
+ * The same journal about FLOWS (#774).
33
+ *
34
+ * ⚠️ **A separate statement rather than a widened one, and that is the decision.** The three kinds
35
+ * do not differ in a value but in the QUESTION they ask: a node is visible when the node is in
36
+ * `allowed`, a flow when its folder is or it carries a grant of its own, a run one join further on.
37
+ * Written as one query with three branches, every caller would pay all three joins to read one
38
+ * kind — and `audit_list` names exactly one kind per call, so there is nothing to gain from it.
39
+ * The feed is the opposite case and rightly written the opposite way: it reads all three at once.
40
+ *
41
+ * ⚠️ **`INNER JOIN flows`, so an event about a purged flow is unreachable** — the same shape the
42
+ * node half has carried since #620, and for the same reason: at a deleted row there is nothing left
43
+ * against which a claim to have been allowed to read it could be tested. Hiding is the safe
44
+ * direction. The feed differs deliberately (it falls back on the purge's own metadata) because a
45
+ * card that says "deleted" is the whole point there; a journal consumer gets the deletion through
46
+ * the parent folder's events instead.
47
+ */
48
+ export declare const auditFlowFeedQuery: string;
49
+ /**
50
+ * And about RUNS, which is the walk one join further: run → flow → folder (#774).
51
+ *
52
+ * ⚠️ The alias is `run_flow` and it is handed to `flowInSubtreeOver` explicitly. Until #767 that
53
+ * guard had `flow` hard-wired, and against a flow reached through a second join every branch but
54
+ * the folder one compared NULL — a flow somebody OWNS but that hangs at the top level answered "not
55
+ * visible" to its own owner. It fails in the safe direction, which is exactly why it needs a test
56
+ * that seeds a run of a flow at the ROOT rather than in a folder.
57
+ */
58
+ export declare const auditRunFeedQuery: string;
31
59
  export declare function createAuditRepository(deps: {
32
60
  db: D1Database;
33
61
  now(): Date;
@@ -1,5 +1,5 @@
1
- import { subtreeBindings, subtreeCte } from "./db-grants.js";
2
- function mapEvent(row) {
1
+ import { flowInSubtree, flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
2
+ function mapEvent(row, resourceType) {
3
3
  let metadata = {};
4
4
  // The column is `NOT NULL DEFAULT '{}'`, but it is written by 11 call sites over
5
5
  // `JSON.stringify` on whatever each one had at hand. A row that cannot be parsed, or that parses
@@ -20,7 +20,10 @@ function mapEvent(row) {
20
20
  id: row.id,
21
21
  actorId: row.actor_id,
22
22
  action: row.action,
23
- resourceType: "node",
23
+ // ⚠️ Taken from the QUESTION, not from the row. Each statement names exactly one
24
+ // `resource_type` in its `WHERE`, so the answer cannot carry another — and reading the column
25
+ // back would add a second truth about the same thing (#774).
26
+ resourceType,
24
27
  resourceId: row.resource_id,
25
28
  metadata,
26
29
  occurredAt: row.occurred_at,
@@ -65,25 +68,83 @@ export const auditFeedQuery = `${subtreeCte}
65
68
  )
66
69
  ORDER BY e.occurred_at ASC, e.id ASC
67
70
  LIMIT ?`;
71
+ /**
72
+ * The same journal about FLOWS (#774).
73
+ *
74
+ * ⚠️ **A separate statement rather than a widened one, and that is the decision.** The three kinds
75
+ * do not differ in a value but in the QUESTION they ask: a node is visible when the node is in
76
+ * `allowed`, a flow when its folder is or it carries a grant of its own, a run one join further on.
77
+ * Written as one query with three branches, every caller would pay all three joins to read one
78
+ * kind — and `audit_list` names exactly one kind per call, so there is nothing to gain from it.
79
+ * The feed is the opposite case and rightly written the opposite way: it reads all three at once.
80
+ *
81
+ * ⚠️ **`INNER JOIN flows`, so an event about a purged flow is unreachable** — the same shape the
82
+ * node half has carried since #620, and for the same reason: at a deleted row there is nothing left
83
+ * against which a claim to have been allowed to read it could be tested. Hiding is the safe
84
+ * direction. The feed differs deliberately (it falls back on the purge's own metadata) because a
85
+ * card that says "deleted" is the whole point there; a journal consumer gets the deletion through
86
+ * the parent folder's events instead.
87
+ */
88
+ export const auditFlowFeedQuery = `${subtreeCte}
89
+ SELECT e.id, e.actor_id, e.action, e.resource_id, e.metadata_json, e.occurred_at
90
+ FROM audit_events e
91
+ JOIN flows flow ON flow.id = e.resource_id
92
+ WHERE e.resource_type = 'flow'
93
+ AND ${flowInSubtree}
94
+ AND (
95
+ ? IS NULL
96
+ OR e.occurred_at > ?
97
+ OR (e.occurred_at = ? AND e.id > ?)
98
+ )
99
+ ORDER BY e.occurred_at ASC, e.id ASC
100
+ LIMIT ?`;
101
+ /**
102
+ * And about RUNS, which is the walk one join further: run → flow → folder (#774).
103
+ *
104
+ * ⚠️ The alias is `run_flow` and it is handed to `flowInSubtreeOver` explicitly. Until #767 that
105
+ * guard had `flow` hard-wired, and against a flow reached through a second join every branch but
106
+ * the folder one compared NULL — a flow somebody OWNS but that hangs at the top level answered "not
107
+ * visible" to its own owner. It fails in the safe direction, which is exactly why it needs a test
108
+ * that seeds a run of a flow at the ROOT rather than in a folder.
109
+ */
110
+ export const auditRunFeedQuery = `${subtreeCte}
111
+ SELECT e.id, e.actor_id, e.action, e.resource_id, e.metadata_json, e.occurred_at
112
+ FROM audit_events e
113
+ JOIN flow_runs run ON run.id = e.resource_id
114
+ JOIN flows run_flow ON run_flow.id = run.flow_id
115
+ WHERE e.resource_type = 'flow-run'
116
+ AND ${flowInSubtreeOver("run_flow.parent_id", "run_flow")}
117
+ AND (
118
+ ? IS NULL
119
+ OR e.occurred_at > ?
120
+ OR (e.occurred_at = ? AND e.id > ?)
121
+ )
122
+ ORDER BY e.occurred_at ASC, e.id ASC
123
+ LIMIT ?`;
68
124
  export function createAuditRepository(deps) {
69
- const page = auditFeedQuery;
70
125
  return {
71
- async listNodeEvents(actor, query) {
126
+ async listEvents(actor, query) {
72
127
  const after = query.after ?? null;
73
- const bindings = [
74
- ...subtreeBindings(actor, "read", deps.now().toISOString()),
75
- after === null ? null : after.occurredAt,
76
- after === null ? null : after.occurredAt,
77
- after === null ? null : after.occurredAt,
78
- after === null ? null : after.id,
79
- query.limit,
80
- ];
128
+ const now = deps.now().toISOString();
129
+ // ⚠️ One reading of the clock for the whole call. Both guards measure grant expiry against
130
+ // it, and two `now()` readings could straddle the moment a grant runs out.
131
+ const guard = query.resourceType === "node"
132
+ ? { page: auditFeedQuery, bindings: subtreeBindings(actor, "read", now) }
133
+ : {
134
+ page: query.resourceType === "flow" ? auditFlowFeedQuery : auditRunFeedQuery,
135
+ // The flow walks need the CTE's bindings and the guard's, in that order — the guard
136
+ // is written into the statement after the CTE.
137
+ bindings: [
138
+ ...subtreeBindings(actor, "read", now),
139
+ ...flowInSubtreeBindings(actor, "read", now),
140
+ ],
141
+ };
81
142
  const result = await deps.db
82
- .prepare(page)
83
- .bind(...bindings)
143
+ .prepare(guard.page)
144
+ .bind(...guard.bindings, after === null ? null : after.occurredAt, after === null ? null : after.occurredAt, after === null ? null : after.occurredAt, after === null ? null : after.id, query.limit)
84
145
  .all();
85
146
  const rows = result.results ?? [];
86
- return { events: rows.map(mapEvent) };
147
+ return { events: rows.map((row) => mapEvent(row, query.resourceType)) };
87
148
  },
88
149
  };
89
150
  }
@@ -1,12 +1,19 @@
1
- import { FeedAction, feedKindOf, } from "@anchrd/intel-contract/feed";
1
+ import { FeedAction, feedResourceTypeOf, } from "@anchrd/intel-contract/feed";
2
2
  import { flowInSubtreeBindings, flowInSubtreeOver, subtreeBindings, subtreeCte, } from "./db-grants.js";
3
- // The two kinds of resource the feed reads, split out of the one action list so the SQL can name
3
+ // The three kinds of resource the feed reads, split out of the one action list so the SQL can name
4
4
  // each side by itself. Derived from the contract rather than written again here: one list that has
5
5
  // to agree with another is one list too many.
6
- const NODE_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "node");
7
- const FLOW_ACTIONS = FeedAction.options.filter((action) => feedKindOf(action) === "flow");
6
+ const NODE_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "node");
7
+ const FLOW_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "flow");
8
+ // ⚠️ The third kind (#767). These are cards ABOUT a flow — `feedKindOf` says `flow` — written as
9
+ // rows about a RUN, so they are found under their own `resource_type` and nowhere near the two
10
+ // lists above. Splitting on `feedResourceTypeOf` rather than on `feedKindOf` is what keeps them out
11
+ // of the flow branch, where `flow.id = e.resource_id` would never match a run id and every one of
12
+ // them would vanish without a word.
13
+ const RUN_ACTIONS = FeedAction.options.filter((action) => feedResourceTypeOf(action) === "flow-run");
8
14
  const NODE_PLACEHOLDERS = NODE_ACTIONS.map(() => "?").join(", ");
9
15
  const FLOW_PLACEHOLDERS = FLOW_ACTIONS.map(() => "?").join(", ");
16
+ const RUN_PLACEHOLDERS = RUN_ACTIONS.map(() => "?").join(", ");
10
17
  function parseMetadata(raw) {
11
18
  // The column is `NOT NULL DEFAULT '{}'` but it is written by eleven call sites over
12
19
  // `JSON.stringify` on whatever each had at hand. A row that cannot be parsed must not take the
@@ -56,9 +63,10 @@ function parseMetadata(raw) {
56
63
  * the whole table, and that is invisible on the first pages and only hurts far down.
57
64
  *
58
65
  * ⚠️ **The index that carries it is `audit_events_time_idx`, NOT `audit_events_feed_idx`** (#763).
59
- * The feed reads two kinds at once, and the older index leads with `resource_type`; under
60
- * `IN ('node', 'flow')` — or, as written here, under two branches joined by `OR` — that column
61
- * stops being an equality prefix, and the ordering falls off it. Measured before `0026` existed:
66
+ * The feed reads all three kinds at once (#767), and the older index leads with `resource_type`;
67
+ * under `IN ('node', 'flow', 'flow-run')` — or, as written here, under three branches joined by
68
+ * `OR` — that column stops being an equality prefix, and the ordering falls off it. Measured before
69
+ * `0026` existed:
62
70
  * `SEARCH e USING INDEX audit_events_feed_idx (resource_type=?)` followed by
63
71
  * `USE TEMP B-TREE FOR ORDER BY`. The older index stays for `audit_list`, which names one kind.
64
72
  *
@@ -66,14 +74,15 @@ function parseMetadata(raw) {
66
74
  * the term may not drive an index. Without it the planner sees two indexable `OR` branches, picks
67
75
  * `MULTI-INDEX OR` over the OLD index — one search per branch, both on `resource_type=?` — and buys
68
76
  * the filtering back at the price of the ordering: `USE TEMP B-TREE FOR ORDER BY` returns, over the
69
- * whole table. Measured both ways. Filtering on kind is cheap here (two values out of three);
70
- * sorting the journal is not.
77
+ * whole table. Measured both ways. Filtering on kind is cheap here; sorting the journal is not.
71
78
  *
72
- * ⚠️ **The two halves ask two DIFFERENT authorization questions, and neither may stand in for the
73
- * other.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
79
+ * ⚠️ **The three branches ask three DIFFERENT authorization questions, and none may stand in for
80
+ * another.** A node is visible when the node itself is in `allowed`; a flow when its FOLDER is, or
74
81
  * when it carries a grant of its own — flows hang in the tree but have nothing beneath them, so
75
82
  * `flowInSubtree` is the rule and `allowed.id = flow.id` would answer about a node that does not
76
- * exist. Both halves fall back on the metadata `parentId` for the one event whose row is gone.
83
+ * exist. A RUN is visible when the flow it belongs to is, which takes one join more to reach
84
+ * (#767). The first two fall back on the metadata `parentId` for the one event whose row is gone;
85
+ * the third deliberately does not — see `RUN_PARENT`.
77
86
  */
78
87
  const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.parentId'))";
79
88
  // ⚠️ A card whose title is NULL names nothing, and `FeedEvent.resourceTitle` refuses it — the reader
@@ -86,17 +95,46 @@ const FLOW_PARENT = "COALESCE(flow.parent_id, json_extract(e.metadata_json, '$.p
86
95
  // ⚠️ The node half cannot reach this state, and that asymmetry is the whole reason it was missed:
87
96
  // there the check is `COALESCE(n.id, …) IN (SELECT id FROM allowed)`, a join against real rows with
88
97
  // no admin short-circuit in front of it, so a deleted node's events drop out on their own.
89
- const HAS_TITLE = "COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) IS NOT NULL";
98
+ const HAS_TITLE = "COALESCE(n.title, flow.title, run_flow.title, json_extract(e.metadata_json, '$.title')) IS NOT NULL";
90
99
  const NODE_PARENT = "COALESCE(n.id, json_extract(e.metadata_json, '$.parentId'))";
100
+ /**
101
+ * The third walk (#767): the run's flow, and that flow's folder.
102
+ *
103
+ * ⚠️ There is **no metadata fallback here**, and that is the decision rather than an omission. The
104
+ * other two kinds fall back on a `parentId` the purge wrote, because a purged node or flow still
105
+ * has events worth reading. A run has no purge of its own — it disappears when its flow is purged,
106
+ * and `flows.purge` already carries that news as its own card. An event whose run row is gone
107
+ * therefore drops out on `HAS_TITLE`, which is the safe direction: the alternative is a card naming
108
+ * a flow nobody can check the reader was allowed to see.
109
+ */
110
+ const RUN_PARENT = "run_flow.parent_id";
111
+ /**
112
+ * What a run card POINTS at: the flow, never the run (#767).
113
+ *
114
+ * Everything else about such a card already names the flow — its title, its path — and the surface
115
+ * turns `resourceId` into a link through `feedKindOf`, which answers `flow` for these two actions.
116
+ * Left as the run id it would navigate to `/flows?select=<run id>`, where nothing matches: an empty
117
+ * pane with no error, which `feed.tsx` names the worst of the three possible outcomes. There is no
118
+ * route onto a single run, so the flow is the honest target rather than a second-best one.
119
+ *
120
+ * ⚠️ `COALESCE` and not a `CASE` on the resource type: `run` is only joined for `flow-run` rows, so
121
+ * it is NULL everywhere else and every other card keeps its own id untouched.
122
+ */
123
+ const RUN_TARGET = "COALESCE(run.flow_id, e.resource_id)";
91
124
  export const feedPageQuery = `${subtreeCte}
92
- SELECT e.id, e.actor_id, e.action, e.resource_id,
93
- COALESCE(n.title, flow.title, json_extract(e.metadata_json, '$.title')) AS resource_title,
94
- COALESCE(n.parent_id, flow.parent_id, json_extract(e.metadata_json, '$.parentId'))
125
+ SELECT e.id, e.actor_id, e.action,
126
+ ${RUN_TARGET} AS resource_id,
127
+ COALESCE(n.title, flow.title, run_flow.title,
128
+ json_extract(e.metadata_json, '$.title')) AS resource_title,
129
+ COALESCE(n.parent_id, flow.parent_id, run_flow.parent_id,
130
+ json_extract(e.metadata_json, '$.parentId'))
95
131
  AS resource_parent_id,
96
132
  e.metadata_json, e.occurred_at
97
133
  FROM audit_events e
98
134
  LEFT JOIN nodes n ON n.id = e.resource_id AND e.resource_type = 'node'
99
135
  LEFT JOIN flows flow ON flow.id = e.resource_id AND e.resource_type = 'flow'
136
+ LEFT JOIN flow_runs run ON run.id = e.resource_id AND e.resource_type = 'flow-run'
137
+ LEFT JOIN flows run_flow ON run_flow.id = run.flow_id
100
138
  WHERE (
101
139
  (
102
140
  +e.resource_type = 'node'
@@ -108,6 +146,11 @@ export const feedPageQuery = `${subtreeCte}
108
146
  AND e.action IN (${FLOW_PLACEHOLDERS})
109
147
  AND ${flowInSubtreeOver(FLOW_PARENT)}
110
148
  )
149
+ OR (
150
+ +e.resource_type = 'flow-run'
151
+ AND e.action IN (${RUN_PLACEHOLDERS})
152
+ AND ${flowInSubtreeOver(RUN_PARENT, "run_flow")}
153
+ )
111
154
  )
112
155
  AND ${HAS_TITLE}
113
156
  AND (? IS NULL OR e.actor_id = ?)
@@ -191,7 +234,12 @@ export function createFeedRepository(deps) {
191
234
  const now = deps.now().toISOString();
192
235
  const page = await deps.db
193
236
  .prepare(feedPageQuery)
194
- .bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now), query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
237
+ .bind(...subtreeBindings(actor, "read", now), ...NODE_ACTIONS, ...FLOW_ACTIONS, ...flowInSubtreeBindings(actor, "read", now), ...RUN_ACTIONS,
238
+ // ⚠️ A SECOND set of bindings for the same guard: `flowInSubtreeOver` writes its
239
+ // placeholders out again for the run branch, and positional binding counts every one of
240
+ // them. Reusing the first set would shift every parameter after it by the width of the
241
+ // guard — which is not a type error and not a syntax error, only wrong answers.
242
+ ...flowInSubtreeBindings(actor, "read", now), query.actor, query.actor, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.occurredAt, before === null ? null : before.id, query.limit)
195
243
  .all();
196
244
  const rows = page.results ?? [];
197
245
  if (rows.length === 0)
@@ -1,5 +1,41 @@
1
1
  import type { FlowRepository } from "../../flows/flows.types.js";
2
2
  import type { D1Database } from "./db.types.js";
3
+ /**
4
+ * One page of runs, newest first — and the flow is part of the STATEMENT rather than a bound value
5
+ * (#774, found in the review of #787).
6
+ *
7
+ * ⚠️ **`WHERE (? IS NULL OR run.flow_id = ?)` is the shape that must not be used here**, however
8
+ * naturally it reads. SQLite picks its plan when the statement is prepared, from the text alone —
9
+ * it never sees the value — so an `OR` on the leading index column drops that column as an equality
10
+ * prefix **in both cases**, including the one where a real flow id was passed. Measured on the same
11
+ * engine and schema, `flow_runs_flow_idx (flow_id, created_at DESC)`:
12
+ *
13
+ * -- WHERE run.flow_id = ?
14
+ * SEARCH run USING INDEX flow_runs_flow_idx (flow_id=?)
15
+ *
16
+ * -- WHERE (? IS NULL OR run.flow_id = ?) — identical for a real id AND for null
17
+ * SCAN run | USE TEMP B-TREE FOR ORDER BY
18
+ *
19
+ * The damage is not on the new door but on the old one: `GET /flows/:flowId/runs` has existed all
20
+ * along and is the frequent call, and it would have started scanning every run in the installation
21
+ * without a single test going red. `db-audit.ts` had the same choice one file over and split its
22
+ * statements per kind; this is that decision, applied where the review found it missing.
23
+ *
24
+ * ⚠️ The two shapes lean on two different indexes, and both are needed. Scoped: `flow_runs_flow_idx`,
25
+ * whose `flow_id` prefix an equality serves. Unscoped: `flow_runs_time_idx (created_at DESC,
26
+ * id DESC)` from `0027`, because "every readable flow" has no one `flow_id` to match on and the
27
+ * ordering has to come from somewhere.
28
+ *
29
+ * ⚠️ `failedOnly` and the cursor are shape too, not bindings, and that is not the same decision —
30
+ * they were already written this way. An absent clause binds nothing, so every caller of this
31
+ * function has to build its bindings from the SAME three flags, in this order: the CTE's, the flow
32
+ * id when scoped, `runVisible`'s, the cursor's three when paged, and the limit.
33
+ */
34
+ export declare function flowRunsPageQuery(shape: {
35
+ scoped: boolean;
36
+ failedOnly: boolean;
37
+ paged: boolean;
38
+ }): string;
3
39
  export declare function createFlowRepository(deps: {
4
40
  db: D1Database;
5
41
  now(): Date;
@@ -74,6 +74,47 @@ function mapVersion(row) {
74
74
  // It expects `flow_runs run` joined to `flows flow`, and its bindings follow the CTE's. The bindings
75
75
  // live in the factory below because `flowInSubtree` now needs the moment expiry is measured against.
76
76
  const runVisible = `(run.initiated_by = ? OR ${flowInSubtree})`;
77
+ /**
78
+ * One page of runs, newest first — and the flow is part of the STATEMENT rather than a bound value
79
+ * (#774, found in the review of #787).
80
+ *
81
+ * ⚠️ **`WHERE (? IS NULL OR run.flow_id = ?)` is the shape that must not be used here**, however
82
+ * naturally it reads. SQLite picks its plan when the statement is prepared, from the text alone —
83
+ * it never sees the value — so an `OR` on the leading index column drops that column as an equality
84
+ * prefix **in both cases**, including the one where a real flow id was passed. Measured on the same
85
+ * engine and schema, `flow_runs_flow_idx (flow_id, created_at DESC)`:
86
+ *
87
+ * -- WHERE run.flow_id = ?
88
+ * SEARCH run USING INDEX flow_runs_flow_idx (flow_id=?)
89
+ *
90
+ * -- WHERE (? IS NULL OR run.flow_id = ?) — identical for a real id AND for null
91
+ * SCAN run | USE TEMP B-TREE FOR ORDER BY
92
+ *
93
+ * The damage is not on the new door but on the old one: `GET /flows/:flowId/runs` has existed all
94
+ * along and is the frequent call, and it would have started scanning every run in the installation
95
+ * without a single test going red. `db-audit.ts` had the same choice one file over and split its
96
+ * statements per kind; this is that decision, applied where the review found it missing.
97
+ *
98
+ * ⚠️ The two shapes lean on two different indexes, and both are needed. Scoped: `flow_runs_flow_idx`,
99
+ * whose `flow_id` prefix an equality serves. Unscoped: `flow_runs_time_idx (created_at DESC,
100
+ * id DESC)` from `0027`, because "every readable flow" has no one `flow_id` to match on and the
101
+ * ordering has to come from somewhere.
102
+ *
103
+ * ⚠️ `failedOnly` and the cursor are shape too, not bindings, and that is not the same decision —
104
+ * they were already written this way. An absent clause binds nothing, so every caller of this
105
+ * function has to build its bindings from the SAME three flags, in this order: the CTE's, the flow
106
+ * id when scoped, `runVisible`'s, the cursor's three when paged, and the limit.
107
+ */
108
+ export function flowRunsPageQuery(shape) {
109
+ return `${subtreeCte}
110
+ SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
111
+ JOIN flows flow ON flow.id = run.flow_id
112
+ WHERE ${shape.scoped ? "run.flow_id = ? AND " : ""}${runVisible}
113
+ ${shape.failedOnly ? "AND run.status = 'failed'" : ""}
114
+ ${shape.paged ? "AND (run.created_at < ? OR (run.created_at = ? AND run.id < ?))" : ""}
115
+ ORDER BY run.created_at DESC, run.id DESC
116
+ LIMIT ?`;
117
+ }
77
118
  function mapStep(row) {
78
119
  return {
79
120
  runId: row.run_id,
@@ -983,29 +1024,28 @@ export function createFlowRepository(deps) {
983
1024
  .first();
984
1025
  return row ? mapRun(row) : null;
985
1026
  },
986
- // Newest first, keyed on `(created_at, id)` so a page holds still while runs keep arriving
987
- // and so `flow_runs_flow_idx (flow_id, created_at DESC)` is the index that serves it.
1027
+ // Newest first, keyed on `(created_at, id)` so a page holds still while runs keep arriving.
988
1028
  //
989
1029
  // ⚠️ `runVisible`, the same predicate `getRunVisible` uses one function up. Seeing the flow is
990
1030
  // not the same as seeing its runs: a library flow is executable for everyone and readable by
991
1031
  // nobody, and the people running it must still find their own runs back.
1032
+ //
1033
+ // ⚠️ A null flow means EVERY flow this actor may read, not "no flow" (#774). The row filter is
1034
+ // untouched by it — `runVisible` decides per row either way — so the wider question cannot
1035
+ // answer with a run the narrow one would have hidden. Which SHAPE of statement asks it is
1036
+ // `flowRunsPageQuery`'s business, and the reason it is a shape rather than a binding is written
1037
+ // there.
992
1038
  async listRunsVisible(actor, input) {
993
- const page = input.cursor
994
- ? {
995
- clause: "AND (run.created_at < ? OR (run.created_at = ? AND run.id < ?))",
996
- bindings: [input.cursor.createdAt, input.cursor.createdAt, input.cursor.id],
997
- }
998
- : { clause: "", bindings: [] };
1039
+ const scoped = input.flowId !== null;
999
1040
  const result = await deps.db
1000
- .prepare(`${subtreeCte}
1001
- SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
1002
- JOIN flows flow ON flow.id = run.flow_id
1003
- WHERE run.flow_id = ? AND ${runVisible}
1004
- ${input.failedOnly ? "AND run.status = 'failed'" : ""}
1005
- ${page.clause}
1006
- ORDER BY run.created_at DESC, run.id DESC
1007
- LIMIT ?`)
1008
- .bind(...readableBindings(actor), input.flowId, ...runVisibleBindings(actor), ...page.bindings, input.limit)
1041
+ .prepare(flowRunsPageQuery({
1042
+ scoped,
1043
+ failedOnly: input.failedOnly,
1044
+ paged: input.cursor !== null && input.cursor !== undefined,
1045
+ }))
1046
+ .bind(...readableBindings(actor), ...(scoped ? [input.flowId] : []), ...runVisibleBindings(actor), ...(input.cursor
1047
+ ? [input.cursor.createdAt, input.cursor.createdAt, input.cursor.id]
1048
+ : []), input.limit)
1009
1049
  .all();
1010
1050
  return (result.results ?? []).map(mapRun);
1011
1051
  },
@@ -115,6 +115,19 @@ export declare function flowVerbBindings(flowId: string, actor: GrantActor, verb
115
115
  * expressions with similar names are two different questions, and the one that goes stale is the
116
116
  * copy.
117
117
  */
118
- export declare function flowInSubtreeOver(parentExpression: string): string;
118
+ /**
119
+ * ⚠️ **The ALIAS is a parameter too, since #767, and leaving it hard-wired was a real fault rather
120
+ * than an inelegance.** The feed's third branch asks about a flow reached through its RUN, so the
121
+ * flow sits under a second join (`run_flow`) while `flow` is NULL on that row. Against the
122
+ * hard-wired name the owner branch and the direct-grant branch both compared NULL — so a flow
123
+ * somebody OWNS but that hangs at the top level, with no folder above it to carry a grant, answered
124
+ * "not visible" to its own owner.
125
+ *
126
+ * ⚠️ It fails in the safe direction, which is exactly what made it worth a test rather than a
127
+ * comment: nothing leaks, a row is only ever missing. A flow filed in a readable FOLDER answers
128
+ * correctly through the third branch, so every test with a folder is green either way — and the two
129
+ * tests that caught it are the ones that seed a flow at the root.
130
+ */
131
+ export declare function flowInSubtreeOver(parentExpression: string, flowAlias?: string): string;
119
132
  export declare const flowInSubtree: string;
120
133
  export declare function flowInSubtreeBindings(actor: GrantActor, verb: ResourceVerb, now: string): unknown[];
@@ -242,12 +242,25 @@ export function flowVerbBindings(flowId, actor, verb, now) {
242
242
  * expressions with similar names are two different questions, and the one that goes stale is the
243
243
  * copy.
244
244
  */
245
- export function flowInSubtreeOver(parentExpression) {
245
+ /**
246
+ * ⚠️ **The ALIAS is a parameter too, since #767, and leaving it hard-wired was a real fault rather
247
+ * than an inelegance.** The feed's third branch asks about a flow reached through its RUN, so the
248
+ * flow sits under a second join (`run_flow`) while `flow` is NULL on that row. Against the
249
+ * hard-wired name the owner branch and the direct-grant branch both compared NULL — so a flow
250
+ * somebody OWNS but that hangs at the top level, with no folder above it to carry a grant, answered
251
+ * "not visible" to its own owner.
252
+ *
253
+ * ⚠️ It fails in the safe direction, which is exactly what made it worth a test rather than a
254
+ * comment: nothing leaks, a row is only ever missing. A flow filed in a readable FOLDER answers
255
+ * correctly through the third branch, so every test with a folder is green either way — and the two
256
+ * tests that caught it are the ones that seed a flow at the root.
257
+ */
258
+ export function flowInSubtreeOver(parentExpression, flowAlias = "flow") {
246
259
  return `(
247
260
  ? = 1
248
- OR flow.owner_id = ?
261
+ OR ${flowAlias}.owner_id = ?
249
262
  OR ${parentExpression} IN (SELECT id FROM allowed)
250
- OR ${flowGrantExists("flow.id")}
263
+ OR ${flowGrantExists(`${flowAlias}.id`)}
251
264
  )`;
252
265
  }
253
266
  export const flowInSubtree = flowInSubtreeOver("flow.parent_id");
@@ -47,25 +47,40 @@ function decodeCursor(cursor) {
47
47
  export function createAudit(deps) {
48
48
  return {
49
49
  async list(actor, input) {
50
- // `resourceType` is validated by the contract enum before it reaches here; the value is
51
- // named again rather than assumed, so adding `flow` to the enum later cannot silently route
52
- // flow events through the node visibility walk.
53
- if (input.resourceType !== "node") {
54
- throw new IntelError(400, "unsupported_resource_type", "Only node events can be listed today. Flow and flow-run events need their own visibility check and are refused rather than omitted, so that an empty answer never has to mean two different things.");
55
- }
56
50
  const after = input.after === undefined ? null : decodeCursor(input.after);
57
- // One more than asked for, so "is there another page" is answered by the same authorized
51
+ // ⚠️ The refusal that stood here until #774 is GONE, and it was doing real work: it named
52
+ // `node` explicitly so that widening the contract enum could not silently route flow events
53
+ // through the node visibility walk. What replaces it is not trust but a repository that takes
54
+ // the kind as an argument and picks a statement per kind — each with its own walk, none able
55
+ // to answer for another. Adding a fourth kind to the enum is now a type error there rather
56
+ // than a wrong answer here, which is the stronger version of the same guard.
57
+ //
58
+ // One more row than asked for, so "is there another page" is answered by the same authorized
58
59
  // query instead of a second one that could disagree with it.
59
- const page = await deps.audit.listNodeEvents(actor, { after, limit: input.limit + 1 });
60
+ const page = await deps.audit.listEvents(actor, {
61
+ after,
62
+ resourceType: input.resourceType,
63
+ limit: input.limit + 1,
64
+ });
60
65
  const hasMore = page.events.length > input.limit;
61
66
  const events = hasMore ? page.events.slice(0, input.limit) : page.events;
62
67
  const last = events.at(-1);
63
68
  return {
64
69
  events,
65
- // ⚠️ The cursor is the LAST DELIVERED row, never the probe row that was dropped. Taking it
66
- // from the probe would skip exactly one event per page — the classic off-by-one that shows
67
- // up as a lead nobody contacted, not as an error.
68
- nextCursor: hasMore && last !== undefined ? encodeCursor(last) : null,
70
+ /**
71
+ * ⚠️ The cursor is the LAST DELIVERED row, never the probe row that was dropped. Taking it
72
+ * from the probe would skip exactly one event per page — the classic off-by-one that shows
73
+ * up as a lead nobody contacted, not as an error.
74
+ *
75
+ * ⚠️ **And it is returned whenever rows were delivered, not only when more follows.** It
76
+ * used to carry `hasMore &&`, which meant a reader catching up received fifty events and no
77
+ * position: it started over on its next run and would have delivered the same fifty for
78
+ * ever. A reader paging through never sees it, because it always asks for a full page —
79
+ * which is why this survived until `anchrd/signals` read the journal for real (`#793`).
80
+ */
81
+ nextCursor: last === undefined ? null : encodeCursor(last),
82
+ // Whether another page may follow. Its own field, because it is its own question.
83
+ hasMore,
69
84
  };
70
85
  },
71
86
  };
@@ -1,4 +1,4 @@
1
- import type { AuditEvent, AuditListRequest, AuditListResponse } from "@anchrd/intel-contract/audit";
1
+ import type { AuditEvent, AuditListRequest, AuditListResponse, AuditResourceType } from "@anchrd/intel-contract/audit";
2
2
  import type { Actor } from "../nodes/nodes.types.js";
3
3
  export interface AuditPosition {
4
4
  occurredAt: string;
@@ -6,13 +6,14 @@ export interface AuditPosition {
6
6
  }
7
7
  export interface AuditQuery {
8
8
  after: AuditPosition | null;
9
+ resourceType: AuditResourceType;
9
10
  limit: number;
10
11
  }
11
12
  export interface AuditPage {
12
13
  events: AuditEvent[];
13
14
  }
14
15
  export interface AuditRepository {
15
- listNodeEvents(actor: Actor, query: AuditQuery): Promise<AuditPage>;
16
+ listEvents(actor: Actor, query: AuditQuery): Promise<AuditPage>;
16
17
  }
17
18
  export interface AuditDeps {
18
19
  audit: AuditRepository;
@@ -1776,11 +1776,16 @@ export function createFlows(deps) {
1776
1776
  async listRuns(actor, input) {
1777
1777
  // The history of a flow that has since been archived stays readable — the runs happened, and
1778
1778
  // archiving is about what may start next (#112).
1779
- const flow = await requireStartedFlow(actor, input.flowId);
1779
+ //
1780
+ // ⚠️ Without a flow there is nothing to require, and skipping the check does NOT widen the
1781
+ // answer (#774): `requireStartedFlow` decides whether one named flow exists FOR this actor,
1782
+ // while which runs appear is decided per row in the repository — their own, plus every run of
1783
+ // a flow they may read. Asking it for a flow nobody named would mean inventing one.
1784
+ const flowId = input.flowId === null ? null : (await requireStartedFlow(actor, input.flowId)).id;
1780
1785
  // One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
1781
1786
  // count over the whole table is never needed to draw a "next" affordance.
1782
1787
  const rows = await deps.repository.listRunsVisible(actor, {
1783
- flowId: flow.id,
1788
+ flowId,
1784
1789
  failedOnly: input.failedOnly,
1785
1790
  limit: input.limit + 1,
1786
1791
  cursor: decodeCursor(input.cursor),
@@ -13,7 +13,7 @@ export interface FlowRunChainEntry {
13
13
  currentNodeId: string | null;
14
14
  }
15
15
  export interface ListRunsQuery {
16
- flowId: string;
16
+ flowId: string | null;
17
17
  failedOnly: boolean;
18
18
  limit: number;
19
19
  cursor: {
package/dist/http/http.js CHANGED
@@ -207,16 +207,25 @@ export function createHttp(deps) {
207
207
  const input = GetNodeInput.parse({ nodeId: context.req.param("nodeId") });
208
208
  return zipResponse(await deps.bundle.exportSubtree(asBundleActor(auth), input.nodeId));
209
209
  });
210
- // The change journal, read forward from a stable position (#620). `nodes/read` and nothing new:
211
- // an audit row about a node says the same thing the node says, so a second capability would be a
212
- // second answer to one question. The per-row check is the service's, and it is the same tree walk
213
- // the node reads go through.
210
+ // The change journal, read forward from a stable position (#620).
211
+ //
212
+ // ⚠️ **The capability follows the KIND asked for** (#774). An audit row says the same thing its
213
+ // resource says, so the journal asks what reading that resource asks: `nodes/read` for `node`,
214
+ // `flows/read` for `flow` and `flow-run` — which is what every other flow-delivering route in
215
+ // this file requires. Until the enum was widened one capability covered the one kind there was;
216
+ // leaving it at `nodes/read` afterwards would have dropped the first of the three checks for
217
+ // exactly the two kinds this ticket opened, and left the Intel ACL as the only filter.
218
+ //
219
+ // ⚠️ The parse therefore runs BEFORE the capability check, which is the one place in this file
220
+ // where it does. That is not a loosening of "capability first, so a refusal costs no storage
221
+ // read": parsing touches the query string and nothing else, and the kind cannot be known any
222
+ // earlier than this. What it costs is that a malformed `resourceType` answers 400 rather than
223
+ // 403 — a sentence about the caller's own parameter, which names nothing of Intel's.
214
224
  //
215
225
  // ⚠️ GET with the cursor in the query string, because the verb is one of the four in the grammar
216
226
  // and the method carries it. `limit` arrives as a string and is coerced here — the contract
217
227
  // wants a number, and a query string has none.
218
228
  app.get("/audit", async (context) => {
219
- const auth = requireCapability(context, "nodes", "read");
220
229
  const limit = context.req.query("limit");
221
230
  const after = context.req.query("after");
222
231
  const input = AuditListRequest.parse({
@@ -224,10 +233,19 @@ export function createHttp(deps) {
224
233
  ...(after === undefined ? {} : { after }),
225
234
  ...(limit === undefined ? {} : { limit: Number(limit) }),
226
235
  });
236
+ const auth = requireCapability(context, input.resourceType === "node" ? "nodes" : "flows", "read");
227
237
  return context.json(await deps.audit.list(asActor(auth), input));
228
238
  });
229
239
  // The same journal read the other way round: newest first, for a person rather than for a
230
- // consumer catching up (#740). `nodes/read` and nothing new, for the reason `/audit` gives.
240
+ // consumer catching up (#740).
241
+ //
242
+ // ⚠️ `nodes/read` for the whole answer, and here that CANNOT follow the kind the way `/audit`
243
+ // above does: one page mixes node and flow cards on purpose, so there is no single kind to pick a
244
+ // capability from. It has been that way since flow cards existed and #767 only added two actions
245
+ // to them, so nothing about it is new — but it does mean somebody holding `nodes/read` without
246
+ // `flows/read` still sees flow activity here, filtered by the Intel ACL alone. Narrowing it means
247
+ // filtering cards by capability inside the feed service, which is a change to the answer rather
248
+ // than to the door: #790.
231
249
  //
232
250
  // ⚠️ A SECOND door and not an option on `/audit`. That one is the contract `anchrd/signals` will
233
251
  // build on, cursor and direction included; a shared signature would put both readers on one
@@ -736,6 +754,28 @@ export function createHttp(deps) {
736
754
  });
737
755
  return context.json(await deps.flows.listRuns(asFlowActor(auth), input));
738
756
  });
757
+ /**
758
+ * The same list across every flow (#774) — "did anything run", which used to take one call per
759
+ * flow and therefore went unasked.
760
+ *
761
+ * ⚠️ It sits at the root beside the other three run routes rather than under a flow, and the
762
+ * reason is the one already written for them in `packages/api/CLAUDE.md`: reading, cancelling and
763
+ * completing a run do not need the flow, and a run outlives it. Naming no flow is exactly that
764
+ * case. One tool answers both addresses — `flow_run_list` with `flowId: null` — which is the same
765
+ * many-to-one the attachment routes have.
766
+ */
767
+ app.get("/flow-runs", async (context) => {
768
+ const auth = requireCapability(context, "flows", "read");
769
+ const url = new URL(context.req.url);
770
+ requireKnownQuery(url, ["failedOnly", "limit", "cursor"]);
771
+ const input = ListFlowRunsInput.parse({
772
+ flowId: null,
773
+ ...(url.searchParams.has("failedOnly") ? { failedOnly: true } : {}),
774
+ ...(url.searchParams.has("limit") ? { limit: Number(url.searchParams.get("limit")) } : {}),
775
+ ...(url.searchParams.has("cursor") ? { cursor: url.searchParams.get("cursor") } : {}),
776
+ });
777
+ return context.json(await deps.flows.listRuns(asFlowActor(auth), input));
778
+ });
739
779
  app.get("/flow-runs/:runId", async (context) => {
740
780
  const auth = requireCapability(context, "flows", "read");
741
781
  const input = GetFlowRunInput.parse({ runId: context.req.param("runId") });
package/dist/mcp/mcp.js CHANGED
@@ -158,15 +158,46 @@ export async function handleMcp(request, deps) {
158
158
  email: deps.authorization.identity.email,
159
159
  name: deps.authorization.identity.name ?? null,
160
160
  }));
161
+ // The change journal, read forward from a stable position (#620). It is what makes Intel the
162
+ // simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
163
+ // and Intel never learns that the consumer exists. That is the D24 test passed rather than
164
+ // argued — remove every reader and nothing piles up here.
165
+ //
166
+ // ⚠️ **The capability follows the KIND asked for** (#774), which is why this tool stands outside
167
+ // both capability blocks rather than inside one. An audit row says the same thing its resource
168
+ // says, so the journal asks what reading that resource asks — `nodes/read` for `node`,
169
+ // `flows/read` for `flow` and `flow-run`. A capability of its own would be a second answer to one
170
+ // question and the two would drift; `nodes/read` for all three would drop the first of the three
171
+ // checks for exactly the two kinds #774 opened.
172
+ //
173
+ // ⚠️ **Offered on EITHER capability, decided per call.** Registration is per connection and the
174
+ // kind arrives per call, so the two cannot be the same gate. Holding one of the two is what makes
175
+ // the tool worth listing at all; which of the three journals it then answers is the handler's
176
+ // question. Same shape, same order, same refusal as `GET /audit` — one business layer, three thin
177
+ // surfaces.
178
+ if (permits(deps.authorization, "nodes", "read") ||
179
+ permits(deps.authorization, "flows", "read")) {
180
+ server.registerTool("audit_list", {
181
+ title: "List change events",
182
+ description: "Read the change journal forward from a stable position: what happened to the resources you may read, oldest first. `resourceType` picks which of the three journals to read — `node`, `flow` or `flow-run` — and each has its own cursor; do not carry one over to another. Pass `nextCursor` back as `after` to continue where you stopped — it says WHERE YOU GOT TO and comes back on the last page as well. Whether another page may follow is `hasMore`; a null `nextCursor` means nothing was returned, not that you have caught up. Events about permanently deleted nodes and flows are never returned, to anybody.",
183
+ inputSchema: AuditListRequest,
184
+ annotations: {
185
+ title: "List change events",
186
+ readOnlyHint: true,
187
+ destructiveHint: false,
188
+ idempotentHint: true,
189
+ openWorldHint: false,
190
+ },
191
+ }, async (input) => {
192
+ // ⚠️ Before the service, so a refusal costs no journal read. A 403 that arrived after the
193
+ // rows were read is a 403 that already read them.
194
+ if (!permits(deps.authorization, input.resourceType === "node" ? "nodes" : "flows", "read")) {
195
+ throw new IntelError(403, "permission_required", "Permission required");
196
+ }
197
+ return text(await deps.audit.list(actor, input));
198
+ });
199
+ }
161
200
  if (permits(deps.authorization, "nodes", "read")) {
162
- // The change journal, read forward from a stable position (#620). It is what makes Intel the
163
- // simplest of all signal sources: a consumer keeps its own cursor and asks what happened since,
164
- // and Intel never learns that the consumer exists. That is the D24 test passed rather than
165
- // argued — remove every reader and nothing piles up here.
166
- //
167
- // ⚠️ `nodes/read`, not a capability of its own. An audit row about a node says the same thing
168
- // the node says; a second capability would be a second answer to one question, and the two
169
- // would drift.
170
201
  // The board as one answer (#648). Four tools, not five: a move is an update of `status` and
171
202
  // `position`, and deleting a task is `node_archive` — nothing here removes a `nodes` row.
172
203
  server.registerTool("board_get", {
@@ -252,18 +283,6 @@ export async function handleMcp(request, deps) {
252
283
  openWorldHint: false,
253
284
  },
254
285
  }, async (input) => text(await deps.boards.update(actor, input)));
255
- server.registerTool("audit_list", {
256
- title: "List change events",
257
- description: "Read the change journal forward from a stable position: what happened to the nodes you may read, oldest first. Pass `nextCursor` back as `after` to continue where you stopped; a null `nextCursor` means you have caught up. Only node events can be listed — flow events need their own visibility check and are refused rather than left out. Events about permanently deleted nodes are never returned, to anybody.",
258
- inputSchema: AuditListRequest,
259
- annotations: {
260
- title: "List change events",
261
- readOnlyHint: true,
262
- destructiveHint: false,
263
- idempotentHint: true,
264
- openWorldHint: false,
265
- },
266
- }, async (input) => text(await deps.audit.list(actor, input)));
267
286
  server.registerTool("feed_list", {
268
287
  title: "List recent activity",
269
288
  description: "Read the change journal backwards: what most recently happened to the nodes you may read, newest first, one entry per event. Each entry carries the node's current title and the folders above it, and only the folders you may open. Pass `nextCursor` back as `before` to keep going further into the past; a null `nextCursor` means you have reached the beginning. Pass `actor` to see one person's or one agent's work alone. This is the human-facing view — for catching up on everything in order, use audit_list, and do not mix the two cursors.",
@@ -929,7 +948,7 @@ export async function handleMcp(request, deps) {
929
948
  // whoever started it, and only their own runs are the calling user's to read out that way.
930
949
  server.registerTool("flow_run_list", {
931
950
  title: "List flow runs",
932
- description: "List the runs of one flow, newest first, with status, start, duration, what triggered them, and for a failed run the step that ended it. Optionally only the failed ones. Runs the calling user may not see are absent, and a failure inside a called flow they may not see is named by the calling step alone.",
951
+ description: 'List flow runs, newest first, with status, start, duration, what triggered them, and for a failed run the step that ended it. Name a `flowId` for one flow, or leave it out for the runs of every flow you may read — which is the way to answer "did anything run at all" without knowing a flow first. Optionally only the failed ones. Runs the calling user may not see are absent, and a failure inside a called flow they may not see is named by the calling step alone.',
933
952
  inputSchema: ListFlowRunsInput,
934
953
  annotations: {
935
954
  title: "List flow runs",
@@ -0,0 +1,27 @@
1
+ -- `flow_run_list` may now be asked without a flow (#774): "did anything run at all", over every
2
+ -- flow the caller may read. `flow_runs_flow_idx (flow_id, created_at DESC)` cannot serve that — it
3
+ -- leads with `flow_id`, and without an equality on that column the ordering falls off the index and
4
+ -- SQLite sorts the whole table instead.
5
+ --
6
+ -- ⚠️ Measured against the real `flowRunsPageQuery({ scoped: false })` before this file existed, on
7
+ -- the same engine and the same schema:
8
+ --
9
+ -- SCAN run
10
+ -- … | USE TEMP B-TREE FOR ORDER BY
11
+ --
12
+ -- That last line is a sorting pass over every run in the installation. It costs nothing while a
13
+ -- customer has fifty runs and everything once an agent has been running for a month.
14
+ --
15
+ -- ⚠️ Both columns, in the order the cursor compares them, for the reason `0022` and `0026` give:
16
+ -- two runs created in the same millisecond are not exotic, and without the tie-break on `id` a
17
+ -- reader continuing on `created_at` alone skips the second one with no error and no log.
18
+ --
19
+ -- ⚠️ `DESC` on both, because this page is newest-first. An ASC index can be walked backwards by
20
+ -- SQLite, but only as long as EVERY term agrees on the direction — matching the `ORDER BY` exactly
21
+ -- is the one shape that keeps that true when a third term is added later.
22
+ --
23
+ -- ⚠️ This does NOT replace `flow_runs_flow_idx`. That one still serves the scoped page — the runs
24
+ -- of ONE flow — which is the older and by far the more frequent question; dropping it would put a
25
+ -- full scan into the call that has none today. Two questions, two shapes, two indexes.
26
+ CREATE INDEX flow_runs_time_idx
27
+ ON flow_runs(created_at DESC, id DESC);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-api",
3
- "version": "0.39.0",
3
+ "version": "0.40.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -43,7 +43,7 @@
43
43
  },
44
44
  "dependencies": {
45
45
  "@anchrd/gate-sdk": "^0.26.0",
46
- "@anchrd/intel-contract": "^0.31.0",
46
+ "@anchrd/intel-contract": "^0.32.0",
47
47
  "@cfworker/json-schema": "^4.1.1",
48
48
  "@modelcontextprotocol/sdk": "^1.30.0",
49
49
  "fflate": "^0.8.3",