backend-skeleton 1.0.0-beta.8 → 1.0.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.
Files changed (48) hide show
  1. package/README.md +122 -12
  2. package/bin/bskel.mjs +738 -12
  3. package/contracts/completeness.mjs +10 -0
  4. package/contracts/emit.mjs +5 -1
  5. package/contracts/export.mjs +26 -3
  6. package/contracts/openapi.mjs +29 -3
  7. package/handles/_engine.mjs +79 -29
  8. package/handles/providers/java-spring/plan.mjs +22 -9
  9. package/handles/providers/java-spring/templates/HandleController.java.tmpl +7 -3
  10. package/handles/providers/java-spring/templates/ResourceResolver.java.tmpl +12 -6
  11. package/handles/providers/python-fastapi/templates/record_snapshot.py.tmpl +101 -39
  12. package/handles/providers/typescript-express/emit.mjs +23 -23
  13. package/handles/providers/typescript-express/observe.mjs +101 -0
  14. package/handles/providers/typescript-express/templates/contractCheck.ts.tmpl +136 -0
  15. package/handles/providers/typescript-express/templates/observeContract.ts.tmpl +146 -0
  16. package/handles/providers/typescript-express/templates/observedSchema.ts.tmpl +116 -0
  17. package/lib/attest.mjs +40 -0
  18. package/lib/cli.mjs +169 -2
  19. package/lib/cross-feature-collisions.mjs +286 -0
  20. package/lib/diff.mjs +35 -0
  21. package/lib/field-dependencies.mjs +355 -0
  22. package/lib/fsutil.mjs +7 -2
  23. package/lib/gate-definitions.mjs +117 -1
  24. package/lib/gates.mjs +5 -1
  25. package/lib/http-server.mjs +358 -0
  26. package/lib/lock.mjs +68 -15
  27. package/lib/patch-kinds.mjs +52 -0
  28. package/lib/patch-transactions.mjs +206 -0
  29. package/lib/serve-ui.html +328 -0
  30. package/lib/workflow.mjs +39 -3
  31. package/package.json +5 -2
  32. package/scanners/adapters/java-spring.mjs +6 -0
  33. package/scanners/adapters/python-fastapi.mjs +9 -1
  34. package/scanners/adapters/typescript-express.mjs +6 -0
  35. package/scanners/db/ddl-apply.mjs +253 -0
  36. package/scanners/db/introspect.mjs +61 -32
  37. package/scanners/db/migrations.mjs +73 -18
  38. package/schemas/cross-feature-report.schema.json +66 -0
  39. package/schemas/cross-feature-resolution.schema.json +28 -0
  40. package/schemas/field-dependency.schema.json +49 -0
  41. package/schemas/gate-attestation.schema.json +22 -0
  42. package/schemas/gate-export.schema.json +58 -0
  43. package/schemas/patch-transaction.schema.json +182 -0
  44. package/schemas/scan-report.schema.json +6 -4
  45. package/schemas/stack-choice.schema.json +12 -1
  46. package/stack/apply.mjs +4 -1
  47. package/stack/catalog/ngrok.yml +8 -2
  48. package/stack/config-apply.mjs +168 -0
@@ -0,0 +1,206 @@
1
+ // D-patch-transactions: the general, kind-agnostic content-addressed patch-transaction primitive.
2
+ // Generalizes lib/patch-approvals.mjs's (A3/D-patch-strategy) per-{resource,field} approval shape
3
+ // into a real lifecycle (propose -> approve -> apply -> rollback) with preimage-hash verification
4
+ // re-checked at every step and content-addressed rollback material saved BEFORE any edit is ever
5
+ // applied. Slice 1 wires exactly one `kind` ("config-apply", stack/config-apply.mjs) -- a later
6
+ // slice can add a second kind without touching this state machine at all.
7
+ //
8
+ // One file per transaction (specs/<featureId>/patch-transactions/<transaction_id>.json), not one
9
+ // growing array like patch-approvals.json -- each record carries real state transitions and
10
+ // references a potentially large rollback blob, so appending to one shared JSON array under a
11
+ // lock on every transition would be worse for both concurrency and the "content-addressed"
12
+ // framing this feature is named for. Blobs (specs/<featureId>/patch-transactions/blobs/<sha256>.
13
+ // blob) dedupe automatically: two transactions proposed against the same preimage produce the
14
+ // identical filename.
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { readJsonIfExists, writeFileAtomic, sha256String } from './fsutil.mjs';
19
+ import { specPath } from './paths.mjs';
20
+ import { validateAgainstSchema, formatSchemaErrors } from './schema-validate.mjs';
21
+ import { withLockSync, withLockAsync } from './lock.mjs';
22
+
23
+ const TRANSACTION_SCHEMA = 'sbf.patch-transaction/1';
24
+
25
+ export function transactionsDir(root, featureId) {
26
+ return specPath(root, featureId, 'patch-transactions');
27
+ }
28
+
29
+ export function blobsDir(root, featureId) {
30
+ return path.join(transactionsDir(root, featureId), 'blobs');
31
+ }
32
+
33
+ export function transactionPath(root, featureId, transactionId) {
34
+ return path.join(transactionsDir(root, featureId), `${transactionId}.json`);
35
+ }
36
+
37
+ export function blobPath(root, featureId, hash) {
38
+ return path.join(blobsDir(root, featureId), `${hash}.blob`);
39
+ }
40
+
41
+ export function newTransactionId() {
42
+ return `pt-${randomUUID()}`;
43
+ }
44
+
45
+ // Idempotent -- identical content always produces the identical path, so re-saving the same
46
+ // preimage (e.g. a second propose against a target nothing has touched since) is a harmless no-op
47
+ // write, never a duplicate.
48
+ export function saveBlob(root, featureId, content) {
49
+ const hash = sha256String(content);
50
+ writeFileAtomic(blobPath(root, featureId, hash), content);
51
+ return hash;
52
+ }
53
+
54
+ export function readBlob(root, featureId, hash) {
55
+ return fs.readFileSync(blobPath(root, featureId, hash), 'utf8');
56
+ }
57
+
58
+ export function loadTransaction(root, featureId, transactionId) {
59
+ const p = transactionPath(root, featureId, transactionId);
60
+ const parsed = readJsonIfExists(p);
61
+ if (parsed === null) return null;
62
+ const { ok, errors } = validateAgainstSchema('patch-transaction.schema.json', parsed);
63
+ if (!ok) {
64
+ throw new Error(`${p}: does not match schemas/patch-transaction.schema.json:\n${formatSchemaErrors(errors).join('\n')}`);
65
+ }
66
+ return parsed;
67
+ }
68
+
69
+ export function saveTransaction(root, featureId, txn) {
70
+ const { ok, errors } = validateAgainstSchema('patch-transaction.schema.json', txn);
71
+ if (!ok) {
72
+ throw new Error(`refusing to write an invalid patch transaction for "${featureId}":\n${formatSchemaErrors(errors).join('\n')}`);
73
+ }
74
+ writeFileAtomic(transactionPath(root, featureId, txn.transaction_id), `${JSON.stringify(txn, null, 2)}\n`);
75
+ return txn;
76
+ }
77
+
78
+ export function listTransactions(root, featureId) {
79
+ const dir = transactionsDir(root, featureId);
80
+ if (!fs.existsSync(dir)) return [];
81
+ return fs.readdirSync(dir)
82
+ .filter((name) => name.endsWith('.json'))
83
+ .map((name) => loadTransaction(root, featureId, name.slice(0, -'.json'.length)))
84
+ .filter(Boolean)
85
+ .sort((a, b) => a.created_at.localeCompare(b.created_at));
86
+ }
87
+
88
+ // `kindPlan` is whatever a kind-specific planner (e.g. stack/config-apply.mjs's planConfigApply())
89
+ // returns -- this module never inspects `kind`-specific fields beyond the ones every plan must
90
+ // carry (target/preimage/proposed_value/postcondition/originalContent/renderedContent).
91
+ // `source` is kind-specific, opaque bookkeeping this module never inspects -- for kind
92
+ // "config-apply" it's `{choice}`, the stack catalog choice id needed to re-locate the same
93
+ // config_check/apply block at approve/apply time (the record's own target.file alone is not
94
+ // enough, since a catalog choice is what the CLI's --choice flag resolves, not stored per-file).
95
+ export function proposeTransaction(root, featureId, kind, kindPlan, source) {
96
+ return withLockSync(root, 'state', () => {
97
+ const transactionId = newTransactionId();
98
+ // The blob is keyed by its OWN content hash, never trusted from kindPlan.preimage.file_hash
99
+ // blindly -- if a planner ever computed that field differently than a plain sha256 of the
100
+ // same bytes, silently trusting it here would save a rollback blob under the WRONG filename,
101
+ // making rollback impossible later. Fail loudly instead; this should never actually fire.
102
+ const blobHash = saveBlob(root, featureId, kindPlan.originalContent);
103
+ if (blobHash !== kindPlan.preimage.file_hash) {
104
+ throw new Error(`internal error: kindPlan.preimage.file_hash ("${kindPlan.preimage.file_hash}") does not match the original content's own hash ("${blobHash}") -- refusing to propose a transaction whose rollback blob would be unrecoverable`);
105
+ }
106
+ const txn = {
107
+ schema: TRANSACTION_SCHEMA,
108
+ transaction_id: transactionId,
109
+ feature_id: featureId,
110
+ kind,
111
+ source,
112
+ target: kindPlan.target,
113
+ preimage: kindPlan.preimage,
114
+ current_value: kindPlan.current_value,
115
+ proposed_value: kindPlan.proposed_value,
116
+ postcondition: kindPlan.postcondition,
117
+ status: 'proposed',
118
+ created_at: new Date().toISOString(),
119
+ };
120
+ return saveTransaction(root, featureId, txn);
121
+ });
122
+ }
123
+
124
+ class StaleTransactionError extends Error {
125
+ constructor(message) {
126
+ super(message);
127
+ this.name = 'StaleTransactionError';
128
+ }
129
+ }
130
+
131
+ function requireFreshPreimage(txn, freshKindPlan) {
132
+ if (freshKindPlan.preimage.region_hash !== txn.preimage.region_hash) {
133
+ throw new StaleTransactionError(
134
+ `transaction "${txn.transaction_id}"'s target has changed since it was proposed -- re-propose it against the current content`,
135
+ );
136
+ }
137
+ }
138
+
139
+ // `freshKindPlan` MUST come from re-running the same kind's planner against the CURRENT on-disk
140
+ // state, never from the stored transaction record -- this is what makes "re-verify preimage at
141
+ // every step" real rather than assumed.
142
+ export function approveTransaction(root, featureId, transactionId, reason, freshKindPlan) {
143
+ if (!reason || !reason.trim()) {
144
+ throw new Error('approving a patch transaction requires a reason -- every approval must be auditable');
145
+ }
146
+ return withLockSync(root, 'state', () => {
147
+ const txn = loadTransaction(root, featureId, transactionId);
148
+ if (!txn) throw new Error(`no patch transaction "${transactionId}" for feature "${featureId}"`);
149
+ requireFreshPreimage(txn, freshKindPlan);
150
+ txn.approval = { reason, at: new Date().toISOString() };
151
+ txn.status = 'approved';
152
+ return saveTransaction(root, featureId, txn);
153
+ });
154
+ }
155
+
156
+ // No --force escape here (mirrors cmdHandlesPatchApprove's permanent "a stale approval is
157
+ // rejected outright, never bypassed" precedent) -- a stale forward edit's collateral effects have
158
+ // never been re-verified, unlike a rollback restoring a known-good, git-recoverable prior state.
159
+ //
160
+ // D-ddl-apply: `executeApply(root, featureId, txn, freshKindPlan)` is an INJECTED, kind-specific
161
+ // async function that performs the actual mutation and returns the fields to merge into `txn.apply`
162
+ // (alongside `at`, added here uniformly). This module still never imports a kind-specific module
163
+ // and never branches on `kind` -- it only calls whatever executor its caller (lib/patch-kinds.mjs)
164
+ // injected, so the "kind-agnostic" framing stays literally true; this is dependency injection, not
165
+ // a switch statement. Uses withLockAsync, not withLockSync -- a naive `async` callback passed to
166
+ // withLockSync would release the lock the instant the callback RETURNS a Promise, not once that
167
+ // Promise RESOLVES, releasing the lock before a live DB write (or any other async executor) has
168
+ // actually finished. withLockAsync's `try { return await fn() } finally { ... }` makes that
169
+ // ordering bug structurally impossible.
170
+ export async function applyTransaction(root, featureId, transactionId, freshKindPlan, executeApply) {
171
+ return withLockAsync(root, 'state', async () => {
172
+ const txn = loadTransaction(root, featureId, transactionId);
173
+ if (!txn) throw new Error(`no patch transaction "${transactionId}" for feature "${featureId}"`);
174
+ if (txn.status !== 'approved') {
175
+ throw new Error(`transaction "${transactionId}" is "${txn.status}", not "approved" -- approve it first`);
176
+ }
177
+ requireFreshPreimage(txn, freshKindPlan);
178
+ const applyResult = await executeApply(root, featureId, txn, freshKindPlan);
179
+ txn.apply = { at: new Date().toISOString(), ...applyResult };
180
+ txn.status = 'applied';
181
+ return saveTransaction(root, featureId, txn);
182
+ });
183
+ }
184
+
185
+ // `executeRollback(root, featureId, txn, {force})` is the injected, kind-specific async restore --
186
+ // for config-apply it re-validates the file-hash drift check and restores from the CAS blob
187
+ // exactly as before (moved verbatim, not reimplemented); for ddl-apply it always throws (rollback
188
+ // of a live DDL apply is out of scope for Slice 1 -- see scanners/db/ddl-apply.mjs's
189
+ // executeDdlRollback). This function itself no longer knows what "restore" means for any kind --
190
+ // the drift-check-then-restore logic lives entirely in the executor now.
191
+ export async function rollbackTransaction(root, featureId, transactionId, reason, { force = false } = {}, executeRollback) {
192
+ if (!reason || !reason.trim()) {
193
+ throw new Error('rolling back a patch transaction requires a reason -- every rollback must be auditable');
194
+ }
195
+ return withLockAsync(root, 'state', async () => {
196
+ const txn = loadTransaction(root, featureId, transactionId);
197
+ if (!txn) throw new Error(`no patch transaction "${transactionId}" for feature "${featureId}"`);
198
+ if (txn.status !== 'applied') {
199
+ throw new Error(`transaction "${transactionId}" is "${txn.status}", not "applied" -- only an applied transaction can be rolled back`);
200
+ }
201
+ const rollbackResult = await executeRollback(root, featureId, txn, { force });
202
+ txn.rollback = { reason, at: new Date().toISOString(), ...(force ? { forced: true } : {}), ...rollbackResult };
203
+ txn.status = 'rolled_back';
204
+ return saveTransaction(root, featureId, txn);
205
+ });
206
+ }
@@ -0,0 +1,328 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <title>bskel serve</title>
6
+ <style>
7
+ :root { color-scheme: light dark; }
8
+ body { font-family: ui-monospace, Menlo, Consolas, monospace; max-width: 960px; margin: 2rem auto; padding: 0 1rem; line-height: 1.4; }
9
+ h1 { font-size: 1.1rem; }
10
+ h2 { font-size: 1rem; margin-top: 2rem; }
11
+ table { border-collapse: collapse; width: 100%; margin-top: 0.5rem; }
12
+ th, td { text-align: left; padding: 0.3rem 0.6rem; border-bottom: 1px solid #8884; font-size: 0.85rem; }
13
+ .resolution-synced { color: #2a2; }
14
+ .resolution-stale { color: #c90; }
15
+ .resolution-unresolved { color: #d33; }
16
+ form { margin-top: 0.5rem; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.4rem; max-width: 720px; }
17
+ form label { display: flex; flex-direction: column; font-size: 0.75rem; gap: 0.15rem; }
18
+ form input { font: inherit; padding: 0.25rem; }
19
+ form button { grid-column: 1 / -1; padding: 0.4rem; font: inherit; cursor: pointer; }
20
+ #formStatus { font-size: 0.85rem; margin-top: 0.4rem; }
21
+ .muted { opacity: 0.6; }
22
+ </style>
23
+ </head>
24
+ <body>
25
+ <h1>bskel serve -- functionality check</h1>
26
+ <p class="muted">Not a redesign of the original Fieldwire mockup -- this exists to prove the API actually works, nothing more.</p>
27
+
28
+ <h2>Nodes</h2>
29
+ <table id="nodesTable"><thead><tr><th>feature</th><th>resourceType</th><th>file</th><th>resolved</th></tr></thead><tbody></tbody></table>
30
+
31
+ <h2>Wires</h2>
32
+ <table id="wiresTable"><thead><tr><th>target</th><th>source</th><th>resolution</th><th>memo</th></tr></thead><tbody></tbody></table>
33
+
34
+ <h2>Declare a new dependency</h2>
35
+ <form id="declareForm">
36
+ <label>feature <input name="feature" required></label>
37
+ <label>resource <input name="resource" required></label>
38
+ <label>field <input name="field" required></label>
39
+ <label>source feature <input name="sourceFeature" required></label>
40
+ <label>source resource <input name="sourceResource" required></label>
41
+ <label>source field <input name="sourceField" required></label>
42
+ <label style="grid-column: 1 / -1">reason <input name="reason" required></label>
43
+ <button type="submit">Declare</button>
44
+ </form>
45
+ <div id="formStatus"></div>
46
+
47
+ <h2>Live DB schema</h2>
48
+ <div id="dbSchemaPanel">
49
+ <p class="muted" id="dbSchemaNote">loading...</p>
50
+ <table id="dbSchemaTable" hidden><thead><tr><th>table</th><th>columns</th><th>primary key</th><th>foreign keys</th><th>indexes</th></tr></thead><tbody></tbody></table>
51
+ </div>
52
+
53
+ <h2>DDL patch transactions</h2>
54
+ <div id="ddlPanel">
55
+ <form id="ddlFeatureForm" style="grid-template-columns: 1fr auto;">
56
+ <label>feature <input name="feature" id="ddlFeatureInput" required></label>
57
+ <button type="submit">Load transactions</button>
58
+ </form>
59
+ <table id="ddlTable"><thead><tr><th>transaction</th><th>kind</th><th>status</th><th>sql</th><th>created</th><th>action</th></tr></thead><tbody></tbody></table>
60
+ <div id="ddlActionStatus"></div>
61
+
62
+ <h3 style="font-size: 0.9rem; margin-top: 1.2rem;">Propose a DDL change</h3>
63
+ <form id="ddlProposeForm">
64
+ <label>feature <input name="feature" id="ddlProposeFeature" required></label>
65
+ <label>database_url_env <input name="databaseUrlEnv" placeholder="e.g. BSKEL_DDL_DATABASE_URL" required></label>
66
+ <label>schema <input name="schema" value="public" required></label>
67
+ <label style="grid-column: 1 / -1">sql_text
68
+ <textarea name="sqlText" rows="4" required style="font: inherit; padding: 0.4rem;" placeholder="CREATE TABLE widgets (id uuid PRIMARY KEY);"></textarea>
69
+ </label>
70
+ <button type="submit">Propose</button>
71
+ </form>
72
+ <div id="ddlProposeStatus"></div>
73
+ </div>
74
+
75
+ <script>
76
+ // D-http-serving-layer: `reason`/`memo` (and every other string field) are free text a human types
77
+ // via `bskel dependency declare --reason/--memo` or this page's own POST form -- never assumed safe
78
+ // to inject as markup. Every cell here is built via textContent, never innerHTML/insertAdjacentHTML,
79
+ // so a value like `<script>...` or `<img onerror=...>` renders as inert text, not executes.
80
+ function td(text) {
81
+ const cell = document.createElement('td');
82
+ cell.textContent = text;
83
+ return cell;
84
+ }
85
+
86
+ async function loadGraph() {
87
+ const res = await fetch('/api/graph');
88
+ const graph = await res.json();
89
+
90
+ const nodesBody = document.querySelector('#nodesTable tbody');
91
+ nodesBody.replaceChildren();
92
+ for (const n of graph.nodes) {
93
+ const tr = document.createElement('tr');
94
+ tr.append(td(n.feature), td(n.resourceType), td(n.file ?? '(unresolved)'), td(String(n.resolved)));
95
+ nodesBody.appendChild(tr);
96
+ }
97
+
98
+ const wiresBody = document.querySelector('#wiresTable tbody');
99
+ wiresBody.replaceChildren();
100
+ for (const w of graph.wires) {
101
+ const tr = document.createElement('tr');
102
+ const target = `${w.feature}/${w.target.resourceType}.${w.target.fieldName}`;
103
+ const source = `${w.source.feature}/${w.source.resourceType}.${w.source.fieldName}`;
104
+ const resolutionCell = td(`${w.resolution}${w.unresolvedReason ? ` (${w.unresolvedReason})` : ''}`);
105
+ resolutionCell.className = `resolution-${w.resolution}`;
106
+ tr.append(td(target), td(source), resolutionCell, td(w.hasMemo ? w.memo : ''));
107
+ wiresBody.appendChild(tr);
108
+ }
109
+ }
110
+
111
+ document.getElementById('declareForm').addEventListener('submit', async (ev) => {
112
+ ev.preventDefault();
113
+ const form = new FormData(ev.target);
114
+ const feature = form.get('feature');
115
+ const statusEl = document.getElementById('formStatus');
116
+ statusEl.textContent = 'declaring...';
117
+ try {
118
+ const res = await fetch(`/api/features/${encodeURIComponent(feature)}/dependencies`, {
119
+ method: 'POST',
120
+ headers: { 'Content-Type': 'application/json' },
121
+ body: JSON.stringify({
122
+ resource: form.get('resource'), field: form.get('field'),
123
+ sourceFeature: form.get('sourceFeature'), sourceResource: form.get('sourceResource'), sourceField: form.get('sourceField'),
124
+ reason: form.get('reason'),
125
+ }),
126
+ });
127
+ const body = await res.json();
128
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
129
+ statusEl.textContent = `declared, gate: ${body.gate.status}`;
130
+ ev.target.reset();
131
+ await loadGraph();
132
+ } catch (err) {
133
+ statusEl.textContent = `error: ${err.message}`;
134
+ }
135
+ });
136
+
137
+ // D-ddl-apply: mirrors td()'s own comment above -- every value rendered here (table/column names,
138
+ // raw sql_text, error messages) is free text this tool never assumes is safe to inject as markup.
139
+ // All via td()/textContent, never innerHTML.
140
+ function tdList(items) {
141
+ return td(items.length ? items.join(', ') : '(none)');
142
+ }
143
+
144
+ // D-ddl-apply (DROP-TABLE-specific confirmation): mirrors scanners/db/ddl-apply.mjs's own
145
+ // requiredConfirmValue() exactly -- the transaction id for a non-drop transaction, or the sorted,
146
+ // comma-joined dropped-table name(s) for one that drops a table. Computed here purely so the
147
+ // placeholder can SHOW the human what to type; the server independently re-derives and enforces
148
+ // the same value, this is not the source of truth.
149
+ function requiredConfirmValue(t) {
150
+ const droppedTables = (t.postcondition?.expected_tables ?? [])
151
+ .filter((x) => x.expect === 'absent')
152
+ .map((x) => x.name)
153
+ .sort();
154
+ return droppedTables.length > 0 ? droppedTables.join(',') : t.transaction_id;
155
+ }
156
+
157
+ async function loadDbSchema() {
158
+ const note = document.getElementById('dbSchemaNote');
159
+ const table = document.getElementById('dbSchemaTable');
160
+ try {
161
+ const res = await fetch('/api/db/schema');
162
+ if (res.status === 404) {
163
+ note.hidden = false;
164
+ note.textContent = 'DDL features are disabled -- start `bskel serve --database-url-env NAME` to enable them.';
165
+ table.hidden = true;
166
+ return;
167
+ }
168
+ const body = await res.json();
169
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
170
+ note.hidden = true;
171
+ table.hidden = false;
172
+ const tbody = table.querySelector('tbody');
173
+ tbody.replaceChildren();
174
+ for (const t of body.tables) {
175
+ const tr = document.createElement('tr');
176
+ tr.append(
177
+ td(t.name),
178
+ td(t.columns.map((c) => `${c.name} ${c.type}${c.nullable ? '' : ' NOT NULL'}`).join(', ') || '(none)'),
179
+ tdList(t.primary_key),
180
+ td(t.foreign_keys.map((f) => `${f.column} -> ${f.references_table}.${f.references_column}`).join(', ') || '(none)'),
181
+ tdList(t.indexes),
182
+ );
183
+ tbody.appendChild(tr);
184
+ }
185
+ if (body.tables.length === 0) {
186
+ const tr = document.createElement('tr');
187
+ tr.appendChild(td('(no tables)'));
188
+ tbody.appendChild(tr);
189
+ }
190
+ } catch (err) {
191
+ note.hidden = false;
192
+ note.textContent = `failed to load /api/db/schema: ${err.message}`;
193
+ table.hidden = true;
194
+ }
195
+ }
196
+
197
+ async function loadDdlTransactions(feature) {
198
+ const tbody = document.querySelector('#ddlTable tbody');
199
+ tbody.replaceChildren();
200
+ if (!feature) return;
201
+ const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions`);
202
+ const body = await res.json();
203
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
204
+ for (const t of body.transactions) {
205
+ const tr = document.createElement('tr');
206
+ const sqlOrTarget = t.kind === 'ddl-apply' ? t.target.sql_text : `${t.target.file} @ ${(t.target.key_path || []).join('.')}`;
207
+ tr.append(td(t.transaction_id), td(t.kind), td(t.status), td(sqlOrTarget), td(t.created_at));
208
+
209
+ const actionCell = document.createElement('td');
210
+ if (t.status === 'proposed') {
211
+ const form = document.createElement('form');
212
+ form.style.gridTemplateColumns = 'none';
213
+ form.style.display = 'flex';
214
+ form.style.gap = '0.3rem';
215
+ const reasonInput = document.createElement('input');
216
+ reasonInput.placeholder = 'reason';
217
+ reasonInput.required = true;
218
+ const btn = document.createElement('button');
219
+ btn.type = 'submit';
220
+ btn.textContent = 'Approve';
221
+ form.append(reasonInput, btn);
222
+ form.addEventListener('submit', async (ev) => {
223
+ ev.preventDefault();
224
+ await runDdlAction(feature, t.transaction_id, 'approve', { reason: reasonInput.value });
225
+ });
226
+ actionCell.appendChild(form);
227
+ } else if (t.status === 'approved') {
228
+ const form = document.createElement('form');
229
+ form.style.gridTemplateColumns = 'none';
230
+ form.style.display = 'flex';
231
+ form.style.gap = '0.3rem';
232
+ const confirmInput = document.createElement('input');
233
+ // Deliberately starts EMPTY, never pre-filled -- a pre-filled confirm field would let a
234
+ // click submit this whole flow (applying a live DDL change) without the human actually
235
+ // re-reading/re-typing anything, defeating the entire point of this friction. The
236
+ // placeholder names the REQUIRED value -- the dropped table name(s), not the opaque
237
+ // transaction id, when this transaction drops a table (see requiredConfirmValue() above).
238
+ confirmInput.placeholder = `type "${requiredConfirmValue(t)}" to confirm`;
239
+ confirmInput.required = true;
240
+ const btn = document.createElement('button');
241
+ btn.type = 'submit';
242
+ btn.textContent = 'Apply';
243
+ form.append(confirmInput, btn);
244
+ form.addEventListener('submit', async (ev) => {
245
+ ev.preventDefault();
246
+ await runDdlAction(feature, t.transaction_id, 'apply', { confirm: confirmInput.value });
247
+ });
248
+ actionCell.appendChild(form);
249
+ } else {
250
+ actionCell.textContent = '(none)';
251
+ }
252
+ tr.appendChild(actionCell);
253
+ tbody.appendChild(tr);
254
+ }
255
+ if (body.transactions.length === 0) {
256
+ const tr = document.createElement('tr');
257
+ tr.appendChild(td('(none proposed)'));
258
+ tbody.appendChild(tr);
259
+ }
260
+ }
261
+
262
+ async function runDdlAction(feature, transactionId, action, body) {
263
+ const statusEl = document.getElementById('ddlActionStatus');
264
+ statusEl.textContent = `${action}ing...`;
265
+ try {
266
+ const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions/${encodeURIComponent(transactionId)}/${action}`, {
267
+ method: 'POST',
268
+ headers: { 'Content-Type': 'application/json' },
269
+ body: JSON.stringify(body),
270
+ });
271
+ const responseBody = await res.json();
272
+ if (!res.ok) throw new Error(responseBody.error ?? `HTTP ${res.status}`);
273
+ statusEl.textContent = `${action} succeeded: ${transactionId} -> ${responseBody.status}`;
274
+ await loadDdlTransactions(feature);
275
+ } catch (err) {
276
+ statusEl.textContent = `error: ${err.message}`;
277
+ }
278
+ }
279
+
280
+ document.getElementById('ddlFeatureForm').addEventListener('submit', async (ev) => {
281
+ ev.preventDefault();
282
+ const feature = document.getElementById('ddlFeatureInput').value;
283
+ try {
284
+ await loadDdlTransactions(feature);
285
+ } catch (err) {
286
+ document.getElementById('ddlActionStatus').textContent = `error: ${err.message}`;
287
+ }
288
+ });
289
+
290
+ document.getElementById('ddlProposeForm').addEventListener('submit', async (ev) => {
291
+ ev.preventDefault();
292
+ const form = new FormData(ev.target);
293
+ const feature = form.get('feature');
294
+ const statusEl = document.getElementById('ddlProposeStatus');
295
+ statusEl.textContent = 'proposing...';
296
+ try {
297
+ const res = await fetch(`/api/features/${encodeURIComponent(feature)}/patch-transactions/propose`, {
298
+ method: 'POST',
299
+ headers: { 'Content-Type': 'application/json' },
300
+ body: JSON.stringify({
301
+ kind: 'ddl-apply',
302
+ databaseUrlEnv: form.get('databaseUrlEnv'),
303
+ schema: form.get('schema'),
304
+ sqlText: form.get('sqlText'),
305
+ }),
306
+ });
307
+ const body = await res.json();
308
+ if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
309
+ statusEl.textContent = `proposed: ${body.transaction_id}`;
310
+ ev.target.reset();
311
+ document.getElementById('ddlFeatureInput').value = feature;
312
+ await loadDdlTransactions(feature);
313
+ } catch (err) {
314
+ statusEl.textContent = `error: ${err.message}`;
315
+ }
316
+ });
317
+
318
+ loadDbSchema();
319
+
320
+ loadGraph().catch((err) => {
321
+ const p = document.createElement('p');
322
+ p.style.color = '#d33';
323
+ p.textContent = `failed to load /api/graph: ${err.message}`;
324
+ document.body.appendChild(p);
325
+ });
326
+ </script>
327
+ </body>
328
+ </html>
package/lib/workflow.mjs CHANGED
@@ -31,9 +31,26 @@ export function listFeatures(root) {
31
31
  const ESTABLISH_COMMAND = {
32
32
  preflight: () => 'bskel preflight',
33
33
  scan: (id) => `bskel scan --feature ${id} --terms <a,b,c>`,
34
+ // D-cross-feature-collision: added in the SAME commit as the gate itself this time -- the
35
+ // ESTABLISH_COMMAND/MUTATING_PREFIXES gap was found live twice already this session for other
36
+ // gates (dependencies/conformance below), so this one is wired up immediately rather than
37
+ // discovered later.
38
+ cross_feature: (id) => `bskel scan cross-feature-check --feature ${id}`,
34
39
  contract: (id) => `bskel contract emit --feature ${id}`,
40
+ // D-field-dependency: this and `conformance` immediately below were BOTH missing before this
41
+ // item -- found live while adding this one, same crash class, fixed together. Neither had ever
42
+ // been exercised through `next` on a stale REQUIRED_WHEN_PRESENT gate (no test covered it) --
43
+ // without an entry here, `computeWorkflowState()` calls `ESTABLISH_COMMAND[gateName](featureId)`
44
+ // directly on the first real stale occurrence and throws a raw TypeError instead of a clean
45
+ // stale report.
46
+ dependencies: (id) => `bskel dependency declare --feature ${id} --resource <Type> --field <name> --source-feature <id> --source-resource <Type> --source-field <name> --reason "..."`,
35
47
  handles: (id) => `bskel handles plan --feature ${id} # then: bskel handles emit --feature ${id}`,
36
48
  stack: () => 'bskel stack apply --choice <id> --apply',
49
+ // D-patch-transactions: added in the SAME commit as the gate itself -- per the
50
+ // `D-cross-feature-collision` entry's own explicit warning above (this exact ESTABLISH_COMMAND
51
+ // gap), already found live twice for other gates before it could recur a third time here.
52
+ patch_transactions: (id) => `bskel patch propose --feature ${id} --choice <id> --target <config_check target path> # then: bskel patch approve ... then bskel patch apply ...`,
53
+ conformance: (id) => `bskel observe emit --feature ${id} # then: run the target app, then bskel observe import --feature ${id} --receipts <path>`,
37
54
  };
38
55
 
39
56
  // awaiting_disposition needs a genuinely different remediation per gate, not a re-run --
@@ -47,17 +64,36 @@ function awaitingDispositionCommand(gateName, featureId) {
47
64
  if (gateName === 'contract') {
48
65
  return `bskel contract waive --feature ${featureId} --code <CODE> (--subject "..."|--all) --reason "..." # or: bskel gate force contract --feature ${featureId} --reason "..." if intentional`;
49
66
  }
67
+ if (gateName === 'cross_feature') {
68
+ return `bskel scan cross-feature-waive --feature ${featureId} --signal resource_type|table|operation_id --identifier <name> --other-feature <id> --reason "..." # or: bskel gate force cross_feature --feature ${featureId} --reason "..." if intentional`;
69
+ }
50
70
  return `bskel gate force ${gateName} --feature ${featureId} --reason "..."`;
51
71
  }
52
72
 
53
73
  // Whether a recommended command WRITES anything (gate state, generated files, applied files) as
54
74
  // opposed to being a pure read (bskel verify, bskel status, bskel next itself). Matched by the
55
75
  // command's own leading "bskel <verb...>" prefix so this can't silently drift from the actual
56
- // command names above.
57
- const MUTATING_PREFIXES = ['bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive', 'bskel gate force', 'bskel handles emit', 'bskel handles plan', 'bskel stack apply'];
76
+ // command names above. `bskel observe emit`/`bskel observe import` were BOTH missing until found
77
+ // live while fixing the identical ESTABLISH_COMMAND gap above (same bug class: neither had ever
78
+ // been exercised through `next` recommending the real `conformance`-gate command, so a real
79
+ // `mutating: false` misclassification on a command that actually writes files + passes a gate went
80
+ // unnoticed) -- see D-field-dependency's COST section in DECISIONS.md.
81
+ const MUTATING_PREFIXES = [
82
+ 'bskel preflight', 'bskel scan', 'bskel feature init', 'bskel contract emit', 'bskel contract waive',
83
+ 'bskel gate force', 'bskel dependency declare', 'bskel dependency remove', 'bskel handles emit',
84
+ 'bskel handles plan', 'bskel stack apply', 'bskel observe emit', 'bskel observe import',
85
+ 'bskel patch propose', 'bskel patch approve', 'bskel patch apply', 'bskel patch rollback',
86
+ ];
87
+
88
+ // Exported (not just inlined into action()) so lib/workflow.mjs's own test suite can assert the
89
+ // classification directly, without needing a full end-to-end fixture that drives a feature all the
90
+ // way to a stale `conformance` gate just to observe one boolean.
91
+ export function isMutatingCommand(command) {
92
+ return MUTATING_PREFIXES.some((p) => command.startsWith(p));
93
+ }
58
94
 
59
95
  function action(command, reason) {
60
- return { command, reason, mutating: MUTATING_PREFIXES.some((p) => command.startsWith(p)) };
96
+ return { command, reason, mutating: isMutatingCommand(command) };
61
97
  }
62
98
 
63
99
  // featureId === null means "repo scope only" -- feature-scoped gates (scan/contract/handles)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backend-skeleton",
3
- "version": "1.0.0-beta.8",
3
+ "version": "1.0.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic gate layer for AI-assisted backend changes -- blocks brownfield collisions and contract/handle drift via disk-hash checks before code ships. Scaffolding codegen included (Java/Spring, Python/FastAPI, TypeScript/Express).",
6
6
  "license": "AGPL-3.0-or-later",
@@ -38,11 +38,14 @@
38
38
  "test:java-compile": "node scripts/java-compile-smoke.mjs",
39
39
  "test:python-import": "node scripts/python-import-smoke.mjs",
40
40
  "test:db-introspect": "node scripts/db-introspect-smoke.mjs",
41
+ "test:ddl-apply": "node scripts/ddl-apply-smoke.mjs",
42
+ "test:cross-feature-fk": "node scripts/cross-feature-fk-smoke.mjs",
41
43
  "test:java-integration": "node scripts/java-integration-smoke.mjs",
42
44
  "test:java-ast": "node scripts/java-ast-smoke.mjs",
43
45
  "test:python-integration": "node scripts/python-integration-smoke.mjs",
44
46
  "test:typescript-compile": "node scripts/typescript-typecheck-smoke.mjs",
45
- "test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs"
47
+ "test:spring-initializr-canary": "node scripts/spring-initializr-canary.mjs",
48
+ "test:all-smoke": "npm test && npm run test:pack && npm run test:java-compile && npm run test:java-integration && npm run test:python-import && npm run test:python-integration && npm run test:db-introspect && npm run test:ddl-apply && npm run test:cross-feature-fk && npm run test:java-ast && npm run test:typescript-compile"
46
49
  },
47
50
  "dependencies": {
48
51
  "ajv": "^8.20.0",
@@ -141,6 +141,12 @@ function extractEntity(text, filePath) {
141
141
  return {
142
142
  className: classDecl ? classDecl.name : path.basename(filePath, '.java'),
143
143
  table: tableMatch ? tableMatch[1] : null,
144
+ // D-cross-feature-collision: this adapter never GUESSES a table name -- `table: null` above
145
+ // already means "no explicit @Table(name=...)", so tableSource is 'explicit' whenever
146
+ // `table` is non-null and null otherwise. Added only for a consistent field shape with the
147
+ // two adapters that DO guess (python-fastapi/typescript-express) -- cross-feature collision
148
+ // detection reads this field, not each adapter's own null-vs-guessed convention.
149
+ tableSource: tableMatch ? 'explicit' : null,
144
150
  idField: idFieldMatch ? idFieldMatch[1] : null,
145
151
  file: filePath,
146
152
  line: classDecl ? lineNumberAt(text, classDecl.index) : null,
@@ -155,7 +155,15 @@ function extractTableEntities(text, file) {
155
155
  const bodyEnd = i + 1 < classMatches.length ? classMatches[i + 1].index : text.length;
156
156
  const body = text.slice(bodyStart, bodyEnd);
157
157
  const idMatch = body.match(/(\w+)\s*:[^=\n]+=\s*Field\([^)]*primary_key\s*=\s*True/);
158
- entities.push({ className: m[1], table: m[1].toLowerCase(), idField: idMatch ? idMatch[1] : null, file, line: lineNumberAt(text, m.index) });
158
+ // D-cross-feature-collision: a real `__tablename__ = "..."` override, previously never
159
+ // checked at all -- `table` fell back to the lowercased class name unconditionally, which
160
+ // silently mis-derives the real table name whenever a class overrides it. Cross-feature
161
+ // collision detection needs to know which case this was: 'explicit' (the real, trustworthy
162
+ // name) vs 'inferred' (a guess that could be wrong), not just a bare string either way.
163
+ const tablenameMatch = body.match(/__tablename__\s*(?::\s*\w+\s*)?=\s*["']([^"']+)["']/);
164
+ const table = tablenameMatch ? tablenameMatch[1] : m[1].toLowerCase();
165
+ const tableSource = tablenameMatch ? 'explicit' : 'inferred';
166
+ entities.push({ className: m[1], table, tableSource, idField: idMatch ? idMatch[1] : null, file, line: lineNumberAt(text, m.index) });
159
167
  }
160
168
  return entities;
161
169
  }