Skip to content

Commit 28d0a7f

Browse files
authored
Merge pull request #186 from PetalNet/feat/console-api-phase2-l2
feat(console-api): add scoped semantic layer
2 parents f58f7fe + 1f02b14 commit 28d0a7f

18 files changed

Lines changed: 2017 additions & 106 deletions

apps/console-api/docs/contracts/CONSOLE-CONTRACTS.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,11 +161,51 @@ never needs the LLM). **Dereferencing a `query_ref` (peek, re-run, stat-binding
161161
always executes as the DEREFERENCING caller** — a shared panel refuses per-viewer, never
162162
replays the author's scopes. NL→query is `POST /api/v1/ask` (Phase 2, additive).
163163

164+
**Shipped L2 query refs (2026-07-13):** every successful structured execution persists its
165+
request, placeholder SQL, columns, timing, and authorizing scope set under the returned ref.
166+
`GET /api/v1/query/:query_ref` peeks that provenance and
167+
`POST /api/v1/query/:query_ref/rerun` executes the stored structured request through the current
168+
caller's RLS scopes (and returns a new ref). Because the record contains filter values and may span
169+
several grants, peek/rerun requires the caller to hold the **complete originating scope set** (a
170+
strict superset is allowed); otherwise it is indistinguishable from a missing ref (404). The
171+
originating grants are never replayed implicitly. Structured `sum` requires counter/delta semantics,
172+
and `avg` over a counter is rejected as `agg_mismatch`. Registered all-event/relationship views
173+
expose only their declared pseudo-fields; dynamic type fields require querying that statistic type.
174+
The request schema reserves `rate`, `fill: zero|previous`, and `coverage`, but this build refuses
175+
those honestly (`bad_agg`/`unsupported_fill`/`unsupported_coverage`) rather than fabricating data.
176+
164177
### 3.2 The statistics catalog — `GET /api/v1/catalog`
165178

166179
The semantic layer, readable, scope-filtered: `schemas/entities/catalog-entry.schema.json`
167180
(type, per-field dimension/measure typing, cardinality, last_emit, emit rate, observed scopes).
168181

182+
L2 is live: `GET /api/v1/catalog` returns the shared read envelope (`freshness`, `items`, always-
183+
present opaque `next_cursor`, and `truncated`) with a 1 MiB server cap. Byte clipping resumes at the
184+
first omitted type; if one entry alone exceeds the cap, the response explicitly lists it in
185+
`omitted_types` and advances, so pagination always makes progress without silently losing a row.
186+
Field descriptors and relationship joins derive transactionally from accepted
187+
emissions; bounded hashed-value sampling classifies dimension cardinality without storing raw
188+
dimension values. Every caller-visible registry projection, cardinality set, and statistic search
189+
document is keyed to one exact scope; the cross-scope aggregate is writer-only coordination state.
190+
Consequently, overlapping access can never reveal field names, joins, or cardinality learned in a
191+
different scope. Conflicting dimension types or measure kind/unit declarations do not silently
192+
rewrite established semantics: the event remains durable and a scoped `registry_drift` curation
193+
proposal is created. Producer registrations carry configurable `max_emit_per_min` (default 6000)
194+
and `max_new_types_per_hour` (default 20); over-cap new types are metadata-only quarantined and
195+
proposed for curation, while same-id retries return the durable rejection without consuming rate
196+
budget until its stored eligibility time; the same body can then be admitted in the new window.
197+
Single-emission cap responses are HTTP 429 with `Retry-After`, `retryable: true`, and
198+
`retry_after_s`; batch item errors carry `retryable: true`.
199+
200+
`GET /api/v1/catalog/search?q=<text>&limit=<1..32>` returns the scope-filtered hybrid retrieval
201+
slice (`schemas/semantic-search-result.schema.json`) over a normalized 75% pgvector cosine / 25%
202+
PostgreSQL full-text score. The corpus contains catalog definitions, registered views, and successful query
203+
structures; query filter values never enter searchable text. Embeddings are currently the hermetic
204+
384-dimension `feature-hash-v1` provider. Registered structured-query views are `events` (the
205+
raw+archive lake) and `relationships` (statistics joined to materialized edges). Governed custom
206+
views publish their direct dimension/measure descriptors in `semantic_views.fields`; validation and
207+
aggregation use those descriptors, and undeclared columns are not addressable.
208+
169209
### 3.3 Typed entity reads — `GET /api/v1/<entity>`
170210

171211
Every read returns the `read-envelope` (freshness + items + pagination). **Every entity item

apps/console-api/docs/contracts/schemas/entities/catalog-entry.schema.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,19 @@
6666
"type": ["number", "null"],
6767
"minimum": 0,
6868
"description": "Recent emission rate (per minute, trailing window); null when too sparse to estimate."
69+
},
70+
"joins": {
71+
"type": "array",
72+
"description": "Relationship shapes observed from accepted emission links.",
73+
"items": {
74+
"type": "object",
75+
"properties": {
76+
"rel": { "type": "string" },
77+
"to_kind": { "type": "string" }
78+
},
79+
"required": ["rel", "to_kind"],
80+
"additionalProperties": false
81+
}
6982
}
7083
},
7184
"required": ["type", "first_seen", "scopes", "dimensions", "measures"],
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://petalcat.dev/schemas/console/query-record.schema.json",
4+
"title": "Durable structured-query provenance record",
5+
"type": "object",
6+
"properties": {
7+
"schema_version": { "const": 1 },
8+
"query_ref": { "type": "string", "pattern": "^q_[A-Za-z0-9_-]+$" },
9+
"request": { "$ref": "query-request.schema.json" },
10+
"sql_text": { "type": "string" },
11+
"columns": { "type": "array", "items": { "type": "object" } },
12+
"row_count": { "type": "integer", "minimum": 0 },
13+
"execution_ms": { "type": ["integer", "null"], "minimum": 0 },
14+
"created_at": { "type": "string", "format": "date-time" }
15+
},
16+
"required": [
17+
"schema_version",
18+
"query_ref",
19+
"request",
20+
"sql_text",
21+
"columns",
22+
"row_count",
23+
"execution_ms",
24+
"created_at"
25+
],
26+
"additionalProperties": false
27+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://petalcat.dev/schemas/console/semantic-search-result.schema.json",
4+
"title": "Semantic corpus search response",
5+
"type": "object",
6+
"properties": {
7+
"schema_version": { "const": 1 },
8+
"items": {
9+
"type": "array",
10+
"items": {
11+
"type": "object",
12+
"properties": {
13+
"kind": { "enum": ["statistic", "query", "view"] },
14+
"source_ref": { "type": "string" },
15+
"content": { "type": "string" },
16+
"score": { "type": "number" }
17+
},
18+
"required": ["kind", "source_ref", "content", "score"],
19+
"additionalProperties": false
20+
}
21+
}
22+
},
23+
"required": ["schema_version", "items"],
24+
"additionalProperties": false
25+
}

apps/console-api/src/app.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export interface EmitOutcome {
2020
readonly duplicate?: boolean;
2121
readonly code?: string;
2222
readonly message?: string;
23+
readonly retryAfterS?: number;
2324
}
2425

2526
export interface Services {
@@ -82,10 +83,20 @@ export async function buildServices(env: Env, opts?: { migrate?: boolean }): Pro
8283
return { ok: false, code: authz.code ?? "emit_denied", message: authz.message ?? "denied" };
8384
let result: AppendResult;
8485
try {
85-
result = await appender.append(e);
86+
result = await appender.append(e, producerSubject, {
87+
maxEmitPerMinute: reg.maxEmitPerMinute,
88+
maxNewTypesPerHour: reg.maxNewTypesPerHour,
89+
});
8690
} catch (err) {
8791
return { ok: false, code: "append_failed", message: String(err) };
8892
}
93+
if (!result.ok)
94+
return {
95+
ok: false,
96+
code: result.code,
97+
message: result.message,
98+
...(result.retryAfterS ? { retryAfterS: result.retryAfterS } : {}),
99+
};
89100
return { ok: true, seq: result.seq, duplicate: result.duplicate };
90101
}
91102

0 commit comments

Comments
 (0)