@synapsor/runner 0.1.7 → 0.1.9

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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,51 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
- No unreleased changes yet.
5
+ ## 0.1.9
6
+
7
+ ### CLI Hygiene
8
+
9
+ - Adds top-level `synapsor-runner --version`, `synapsor-runner -v`, and
10
+ `synapsor-runner version` output so published package checks do not look like
11
+ an unknown command.
12
+
13
+ ## 0.1.8
14
+
15
+ ### DSL / JSON Contract Parity
16
+
17
+ - Adds portable spec fields for capability `returns_hint`, proposal
18
+ `numeric_bounds`, and proposal `transition_guards` so reviewed safety
19
+ metadata can live in canonical contracts instead of runner-private config.
20
+ - Extends the DSL with `DESCRIPTION`, `RETURNS HINT`, arg descriptions,
21
+ numeric arg min/max, text `MAX LENGTH`, patch `BOUND`, and `TRANSITION`
22
+ clauses.
23
+ - Adds DSL warnings and `--strict` mode so weak proposal contracts fail CI
24
+ instead of silently compiling.
25
+ - Preserves compiled bounds through `contracts: []` into Runner propose-time
26
+ enforcement and accepts pure-contract configs with `capabilities: []`.
27
+ - Adds `docs/dsl-json-parity.md` so developers can see which fields are
28
+ authored in DSL, validated in JSON, enforced by Runner, and accepted by
29
+ C++/Cloud.
30
+
31
+ ### Cloud Registry Push
32
+
33
+ - Wires non-dry-run `synapsor-runner cloud push` to the Synapsor Cloud control
34
+ API. The CLI validates locally, posts normalized `@synapsor/spec` JSON, and
35
+ reports Cloud contract/version/digest details only after the server confirms
36
+ storage.
37
+ - Keeps `--dry-run` network-free and updates error handling for invalid tokens,
38
+ missing workspace permissions, validation errors, conflicts, and network
39
+ failures without printing bearer tokens.
40
+ - Documents the project-scoped Cloud registry path and backend runner-bundle
41
+ export foundation.
42
+
43
+ ### Release Verification
44
+
45
+ - Adds `corepack pnpm test:live-apply` as the documented Docker-backed live
46
+ apply smoke. It aliases the existing MCP local examples proof and verifies
47
+ proposal diffs, approval outside MCP, guarded writeback, idempotent retry,
48
+ stale-row conflict, receipts, and replay against disposable Postgres/MySQL
49
+ databases.
6
50
 
7
51
  ## 0.1.7
8
52
 
package/README.md CHANGED
@@ -235,6 +235,8 @@ CREATE AGENT CONTEXT local_operator
235
235
  END
236
236
 
237
237
  CREATE CAPABILITY billing.inspect_invoice
238
+ DESCRIPTION 'Inspect one invoice in the trusted tenant before proposing a waiver.'
239
+ RETURNS HINT 'Returns reviewed invoice fields plus evidence/query-audit handles.'
238
240
  USING CONTEXT local_operator
239
241
  SOURCE local_postgres
240
242
  ON public.invoices
@@ -242,13 +244,15 @@ CREATE CAPABILITY billing.inspect_invoice
242
244
  TENANT KEY tenant_id
243
245
  CONFLICT GUARD updated_at
244
246
  LOOKUP invoice_id BY id
245
- ARG invoice_id STRING REQUIRED MAX 128
247
+ ARG invoice_id STRING REQUIRED MAX LENGTH 128 DESCRIPTION 'Invoice id such as INV-3001.'
246
248
  ALLOW READ id, tenant_id, status, late_fee_cents, updated_at
247
249
  REQUIRE EVIDENCE
248
250
  MAX ROWS 1
249
251
  END
250
252
 
251
253
  CREATE CAPABILITY billing.propose_late_fee_waiver
254
+ DESCRIPTION 'Propose waiving one invoice late fee after inspecting invoice and policy evidence.'
255
+ RETURNS HINT 'Returns a review-required proposal id, exact diff, evidence handle, and source_database_changed:false.'
252
256
  USING CONTEXT local_operator
253
257
  SOURCE local_postgres
254
258
  ON public.invoices
@@ -256,8 +260,8 @@ CREATE CAPABILITY billing.propose_late_fee_waiver
256
260
  TENANT KEY tenant_id
257
261
  CONFLICT GUARD updated_at
258
262
  LOOKUP invoice_id BY id
259
- ARG invoice_id STRING REQUIRED MAX 128
260
- ARG reason TEXT REQUIRED MAX 500
263
+ ARG invoice_id STRING REQUIRED MAX LENGTH 128 DESCRIPTION 'Invoice id such as INV-3001.'
264
+ ARG reason TEXT REQUIRED MAX LENGTH 500 DESCRIPTION 'Business reason for the proposed waiver.'
261
265
  ALLOW READ id, tenant_id, status, late_fee_cents, waiver_reason, updated_at
262
266
  REQUIRE EVIDENCE
263
267
  MAX ROWS 1
@@ -265,6 +269,7 @@ CREATE CAPABILITY billing.propose_late_fee_waiver
265
269
  ALLOW WRITE late_fee_cents, waiver_reason
266
270
  PATCH late_fee_cents = 0
267
271
  PATCH waiver_reason = ARG reason
272
+ BOUND late_fee_cents 0..10000
268
273
  APPROVAL ROLE billing_lead
269
274
  WRITEBACK DIRECT SQL
270
275
  END
@@ -273,20 +278,33 @@ END
273
278
  Compile, validate, and serve it:
274
279
 
275
280
  ```bash
276
- synapsor-runner dsl compile ./contract.synapsor --out ./synapsor.contract.json
281
+ synapsor-runner dsl compile ./contract.synapsor --out ./synapsor.contract.json --strict
277
282
  synapsor-runner contract validate ./synapsor.contract.json
278
283
  synapsor-runner contract bundle ./synapsor.contract.json --out ./synapsor-runner-bundle
279
284
  synapsor-runner cloud push ./synapsor.contract.json --dry-run
285
+ synapsor-runner cloud push ./synapsor.contract.json \
286
+ --api-url "$SYNAPSOR_CLOUD_BASE_URL" \
287
+ --token "$SYNAPSOR_CLOUD_TOKEN" \
288
+ --workspace "$SYNAPSOR_PROJECT_ID" \
289
+ --name billing-late-fee
280
290
  cd ./synapsor-runner-bundle
281
291
  cp .env.example .env # fill in and export your read-only database values
282
292
  synapsor-runner mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
283
293
  ```
284
294
 
295
+ `--strict` treats DSL safety warnings as errors, so CI catches proposal
296
+ capabilities that are missing descriptions, returns hints, or numeric patch
297
+ bounds.
298
+
285
299
  The `contract bundle` step generates `synapsor.runner.json` (with env-var
286
300
  placeholders) inside the bundle directory, which is why `mcp serve` runs from
287
301
  there. The server keeps stdout clean for MCP protocol frames and prints its
288
302
  ready line on stderr.
289
303
 
304
+ See [`docs/dsl-json-parity.md`](docs/dsl-json-parity.md) for the current
305
+ field-by-field support matrix across JSON spec, DSL, runner enforcement,
306
+ C++/Cloud compatibility, and Cloud push.
307
+
290
308
  Your `synapsor.runner.json` supplies local wiring: database env var names,
291
309
  SQLite store path, MCP transport settings, and local debug options. The
292
310
  contract supplies the portable meaning: contexts, capabilities, evidence,
@@ -406,6 +424,18 @@ and proves the proposal-first write path:
406
424
  npx -y -p @synapsor/runner synapsor-runner demo
407
425
  ```
408
426
 
427
+ For contributor/release verification from a checkout, the live apply smoke uses
428
+ disposable Postgres/MySQL containers and the official MCP stdio client transport:
429
+
430
+ ```bash
431
+ corepack pnpm test:live-apply
432
+ ```
433
+
434
+ It verifies semantic tool listing, proposal diffs, source rows unchanged before
435
+ approval, guarded writeback, idempotent retry, stale-row conflict, receipts, and
436
+ replay. See [`docs/local-mode.md`](docs/local-mode.md#local-mcp-smoke) for
437
+ prerequisites and expected output.
438
+
409
439
  After the demo prints its generated config and store path, run the happy path it
410
440
  prints. The shape is:
411
441
 
@@ -1205,6 +1235,11 @@ Portable contracts can be checked locally before Cloud import:
1205
1235
  synapsor-runner contract validate ./synapsor.contract.json
1206
1236
  synapsor-runner contract bundle ./synapsor.contract.json --out ./synapsor-runner-bundle
1207
1237
  synapsor-runner cloud push ./synapsor.contract.json --dry-run
1238
+ synapsor-runner cloud push ./synapsor.contract.json \
1239
+ --api-url "$SYNAPSOR_CLOUD_BASE_URL" \
1240
+ --token "$SYNAPSOR_CLOUD_TOKEN" \
1241
+ --workspace "$SYNAPSOR_PROJECT_ID" \
1242
+ --name billing-late-fee
1208
1243
  ```
1209
1244
 
1210
1245
  ## Current Limitations
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAYA,OAAO,EAAoN,KAAK,WAAW,EAA2F,MAAM,6BAA6B,CAAC;AAoB1W,OAAO,EAA6G,KAAK,YAAY,EAAwB,MAAM,2BAA2B,CAAC;AAE/L,OAAO,EAOL,KAAK,gBAAgB,EAEtB,MAAM,mCAAmC,CAAC;AA6U3C,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqD1D;AA+DD,KAAK,SAAS,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE9E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IACP,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;CACvC,GACL,OAAO,CAAC,MAAM,CAAC,CA2TjB;AAyrED,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAS/H"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":";AAYA,OAAO,EAAoN,KAAK,WAAW,EAA2F,MAAM,6BAA6B,CAAC;AAoB1W,OAAO,EAA6G,KAAK,YAAY,EAAwB,MAAM,2BAA2B,CAAC;AAE/L,OAAO,EAOL,KAAK,gBAAgB,EAEtB,MAAM,mCAAmC,CAAC;AA6U3C,wBAAsB,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAyD1D;AA+DD,KAAK,SAAS,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,YAAY,CAAC,EAAE,MAAM,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;AAE9E,wBAAsB,aAAa,CACjC,IAAI,EAAE,MAAM,EAAE,EACd,OAAO,GAAE;IACP,GAAG,CAAC,EAAE,SAAS,CAAC;IAChB,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;IACxB,UAAU,CAAC,EAAE,gBAAgB,CAAC;IAC9B,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB,MAAM,CAAC,EAAE,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;CACvC,GACL,OAAO,CAAC,MAAM,CAAC,CA2TjB;AAmsED,wBAAsB,0BAA0B,CAAC,GAAG,EAAE,YAAY,EAAE,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,CAAC,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAS/H"}
package/dist/runner.mjs CHANGED
@@ -890,6 +890,7 @@ function validateExecutorAuth(value, path4, strict, errors) {
890
890
  function validateCapabilities(value, sources, contexts, executors, mode, strict, errors, warnings, hasContracts = false) {
891
891
  if (mode === "cloud" && value === void 0) return;
892
892
  if (hasContracts && value === void 0) return;
893
+ if (hasContracts && Array.isArray(value) && value.length === 0) return;
893
894
  if (!Array.isArray(value) || value.length === 0) {
894
895
  errors.push({ path: "$.capabilities", code: "CAPABILITIES_REQUIRED", message: "At least one capability is required." });
895
896
  return;
@@ -2812,13 +2813,15 @@ var METADATA_KEYS = /* @__PURE__ */ new Set(["name", "description", "version", "
2812
2813
  var RESOURCE_KEYS = /* @__PURE__ */ new Set(["name", "engine", "schema", "table", "type", "primary_key", "tenant_key", "conflict_key", "single_tenant_dev"]);
2813
2814
  var CONTEXT_KEYS2 = /* @__PURE__ */ new Set(["name", "description", "bindings", "tenant_binding", "principal_binding"]);
2814
2815
  var BINDING_KEYS = /* @__PURE__ */ new Set(["name", "source", "key", "required"]);
2815
- var CAPABILITY_KEYS2 = /* @__PURE__ */ new Set(["name", "description", "kind", "context", "source", "subject", "args", "lookup", "visible_fields", "kept_out_fields", "evidence", "max_rows", "proposal"]);
2816
+ var CAPABILITY_KEYS2 = /* @__PURE__ */ new Set(["name", "description", "returns_hint", "kind", "context", "source", "subject", "args", "lookup", "visible_fields", "kept_out_fields", "evidence", "max_rows", "proposal"]);
2816
2817
  var SUBJECT_KEYS = /* @__PURE__ */ new Set(["resource", "schema", "table", "primary_key", "tenant_key", "conflict_key", "single_tenant_dev"]);
2817
2818
  var ARG_KEYS2 = /* @__PURE__ */ new Set(["type", "description", "required", "max_length", "minimum", "maximum", "enum"]);
2818
2819
  var LOOKUP_KEYS2 = /* @__PURE__ */ new Set(["id_from_arg"]);
2819
2820
  var EVIDENCE_KEYS = /* @__PURE__ */ new Set(["required", "sources", "query_audit", "handle_prefix"]);
2820
- var PROPOSAL_KEYS = /* @__PURE__ */ new Set(["action", "allowed_fields", "patch", "conflict_guard", "approval", "writeback"]);
2821
+ var PROPOSAL_KEYS = /* @__PURE__ */ new Set(["action", "allowed_fields", "patch", "numeric_bounds", "transition_guards", "conflict_guard", "approval", "writeback"]);
2821
2822
  var PATCH_KEYS = /* @__PURE__ */ new Set(["fixed", "from_arg"]);
2823
+ var NUMERIC_BOUND_KEYS2 = /* @__PURE__ */ new Set(["minimum", "maximum"]);
2824
+ var TRANSITION_GUARD_KEYS2 = /* @__PURE__ */ new Set(["from_column", "allowed"]);
2822
2825
  var CONFLICT_GUARD_KEYS2 = /* @__PURE__ */ new Set(["column", "weak_guard_ack"]);
2823
2826
  var APPROVAL_KEYS2 = /* @__PURE__ */ new Set(["mode", "required_role"]);
2824
2827
  var WRITEBACK_KEYS2 = /* @__PURE__ */ new Set(["mode", "executor", "idempotency_key"]);
@@ -2997,6 +3000,8 @@ function validateCapabilities2(value, contextNames, resourceNames, errors, warni
2997
3000
  errors.push({ path: `${path4}.name`, code: "INVALID_CAPABILITY_NAME", message: "capability name must be namespace.name." });
2998
3001
  else
2999
3002
  addUnique(names, capability.name, `${path4}.name`, "DUPLICATE_CAPABILITY_NAME", errors);
3003
+ if (capability.returns_hint !== void 0 && !isNonEmptyString2(capability.returns_hint))
3004
+ errors.push({ path: `${path4}.returns_hint`, code: "INVALID_RETURNS_HINT", message: "returns_hint must be a non-empty string." });
3000
3005
  if (!["read", "proposal", "external_action", "answer_with_evidence"].includes(String(capability.kind)))
3001
3006
  errors.push({ path: `${path4}.kind`, code: "INVALID_CAPABILITY_KIND", message: "kind must be read, proposal, external_action, or answer_with_evidence." });
3002
3007
  if (!isNonEmptyString2(capability.context) || !contextNames.has(capability.context))
@@ -3059,6 +3064,20 @@ function validateArgs2(value, path4, errors) {
3059
3064
  checkUnknownKeys2(arg, ARG_KEYS2, argPath, errors);
3060
3065
  if (!["string", "number", "boolean"].includes(String(arg.type)))
3061
3066
  errors.push({ path: `${argPath}.type`, code: "INVALID_ARG_TYPE", message: "arg type must be string, number, or boolean." });
3067
+ if (arg.description !== void 0 && !isNonEmptyString2(arg.description))
3068
+ errors.push({ path: `${argPath}.description`, code: "INVALID_ARG_DESCRIPTION", message: "arg description must be a non-empty string." });
3069
+ if (arg.max_length !== void 0 && (!Number.isInteger(arg.max_length) || Number(arg.max_length) <= 0))
3070
+ errors.push({ path: `${argPath}.max_length`, code: "INVALID_MAX_LENGTH", message: "max_length must be a positive integer." });
3071
+ if ((arg.minimum !== void 0 || arg.maximum !== void 0) && arg.type !== "number")
3072
+ errors.push({ path: argPath, code: "NUMERIC_BOUNDS_REQUIRE_NUMBER", message: "minimum/maximum can only be used with number args." });
3073
+ if (arg.minimum !== void 0 && !isFiniteNumber2(arg.minimum))
3074
+ errors.push({ path: `${argPath}.minimum`, code: "INVALID_MINIMUM", message: "minimum must be a finite number." });
3075
+ if (arg.maximum !== void 0 && !isFiniteNumber2(arg.maximum))
3076
+ errors.push({ path: `${argPath}.maximum`, code: "INVALID_MAXIMUM", message: "maximum must be a finite number." });
3077
+ if (isFiniteNumber2(arg.minimum) && isFiniteNumber2(arg.maximum) && Number(arg.minimum) > Number(arg.maximum))
3078
+ errors.push({ path: argPath, code: "INVALID_ARG_NUMERIC_RANGE", message: "minimum must be less than or equal to maximum." });
3079
+ if (arg.enum !== void 0 && (!Array.isArray(arg.enum) || arg.enum.length === 0))
3080
+ errors.push({ path: `${argPath}.enum`, code: "INVALID_ARG_ENUM", message: "enum must be a non-empty array when provided." });
3062
3081
  }
3063
3082
  }
3064
3083
  function validateLookup2(value, path4, args, errors) {
@@ -3109,6 +3128,8 @@ function validateProposalAction(value, path4, errors) {
3109
3128
  errors.push({ path: patchPath, code: "PATCH_BINDING_REQUIRED", message: "patch binding must include fixed or from_arg." });
3110
3129
  }
3111
3130
  }
3131
+ validateNumericBounds2(value.numeric_bounds, value.patch, `${path4}.numeric_bounds`, errors);
3132
+ validateTransitionGuards2(value.transition_guards, value.patch, `${path4}.transition_guards`, errors);
3112
3133
  if (value.conflict_guard !== void 0) {
3113
3134
  if (!isRecord3(value.conflict_guard))
3114
3135
  errors.push({ path: `${path4}.conflict_guard`, code: "CONFLICT_GUARD_NOT_OBJECT", message: "conflict_guard must be an object." });
@@ -3128,6 +3149,74 @@ function validateProposalAction(value, path4, errors) {
3128
3149
  errors.push({ path: `${path4}.writeback.mode`, code: "INVALID_WRITEBACK_MODE", message: "writeback.mode must be direct_sql, app_handler, cloud_worker, or none." });
3129
3150
  }
3130
3151
  }
3152
+ function validateNumericBounds2(value, patch, path4, errors) {
3153
+ if (value === void 0)
3154
+ return;
3155
+ if (!isRecord3(value)) {
3156
+ errors.push({ path: path4, code: "NUMERIC_BOUNDS_NOT_OBJECT", message: "numeric_bounds must map patch fields to numeric bounds." });
3157
+ return;
3158
+ }
3159
+ const patchFields = isRecord3(patch) ? new Set(Object.keys(patch)) : /* @__PURE__ */ new Set();
3160
+ for (const [field, bounds] of Object.entries(value)) {
3161
+ const boundPath = `${path4}.${field}`;
3162
+ if (!isSafeIdentifier2(field))
3163
+ errors.push({ path: boundPath, code: "INVALID_NUMERIC_BOUND_FIELD", message: "numeric_bounds keys must be safe patch fields." });
3164
+ if (!patchFields.has(field))
3165
+ errors.push({ path: boundPath, code: "NUMERIC_BOUND_PATCH_FIELD_REQUIRED", message: "numeric_bounds can only constrain fields in proposal.patch." });
3166
+ if (!isRecord3(bounds)) {
3167
+ errors.push({ path: boundPath, code: "NUMERIC_BOUND_NOT_OBJECT", message: "numeric bound must be an object." });
3168
+ continue;
3169
+ }
3170
+ checkUnknownKeys2(bounds, NUMERIC_BOUND_KEYS2, boundPath, errors);
3171
+ const hasMinimum = bounds.minimum !== void 0;
3172
+ const hasMaximum = bounds.maximum !== void 0;
3173
+ if (!hasMinimum && !hasMaximum)
3174
+ errors.push({ path: boundPath, code: "NUMERIC_BOUND_EMPTY", message: "numeric bound must define minimum, maximum, or both." });
3175
+ if (hasMinimum && !isFiniteNumber2(bounds.minimum))
3176
+ errors.push({ path: `${boundPath}.minimum`, code: "INVALID_MINIMUM", message: "minimum must be a finite number." });
3177
+ if (hasMaximum && !isFiniteNumber2(bounds.maximum))
3178
+ errors.push({ path: `${boundPath}.maximum`, code: "INVALID_MAXIMUM", message: "maximum must be a finite number." });
3179
+ if (isFiniteNumber2(bounds.minimum) && isFiniteNumber2(bounds.maximum) && Number(bounds.minimum) > Number(bounds.maximum)) {
3180
+ errors.push({ path: boundPath, code: "INVALID_NUMERIC_RANGE", message: "minimum must be less than or equal to maximum." });
3181
+ }
3182
+ }
3183
+ }
3184
+ function validateTransitionGuards2(value, patch, path4, errors) {
3185
+ if (value === void 0)
3186
+ return;
3187
+ if (!isRecord3(value)) {
3188
+ errors.push({ path: path4, code: "TRANSITION_GUARDS_NOT_OBJECT", message: "transition_guards must map patch fields to allowed state transitions." });
3189
+ return;
3190
+ }
3191
+ const patchFields = isRecord3(patch) ? new Set(Object.keys(patch)) : /* @__PURE__ */ new Set();
3192
+ for (const [field, guard] of Object.entries(value)) {
3193
+ const guardPath = `${path4}.${field}`;
3194
+ if (!isSafeIdentifier2(field))
3195
+ errors.push({ path: guardPath, code: "INVALID_TRANSITION_GUARD_FIELD", message: "transition_guards keys must be safe patch fields." });
3196
+ if (!patchFields.has(field))
3197
+ errors.push({ path: guardPath, code: "TRANSITION_GUARD_PATCH_FIELD_REQUIRED", message: "transition_guards can only constrain fields in proposal.patch." });
3198
+ if (!isRecord3(guard)) {
3199
+ errors.push({ path: guardPath, code: "TRANSITION_GUARD_NOT_OBJECT", message: "transition guard must be an object." });
3200
+ continue;
3201
+ }
3202
+ checkUnknownKeys2(guard, TRANSITION_GUARD_KEYS2, guardPath, errors);
3203
+ if (guard.from_column !== void 0 && !isSafeIdentifier2(guard.from_column)) {
3204
+ errors.push({ path: `${guardPath}.from_column`, code: "INVALID_TRANSITION_FROM_COLUMN", message: "from_column must be a safe identifier." });
3205
+ }
3206
+ if (!isRecord3(guard.allowed) || Object.keys(guard.allowed).length === 0) {
3207
+ errors.push({ path: `${guardPath}.allowed`, code: "TRANSITION_ALLOWED_REQUIRED", message: "transition guard must define allowed transitions." });
3208
+ continue;
3209
+ }
3210
+ for (const [from, toValues] of Object.entries(guard.allowed)) {
3211
+ const allowedPath = `${guardPath}.allowed.${from}`;
3212
+ if (!isNonEmptyString2(from))
3213
+ errors.push({ path: allowedPath, code: "TRANSITION_FROM_REQUIRED", message: "transition source state must be a non-empty string." });
3214
+ if (!Array.isArray(toValues) || toValues.length === 0 || toValues.some((item) => !isNonEmptyString2(item))) {
3215
+ errors.push({ path: allowedPath, code: "TRANSITION_TO_VALUES_REQUIRED", message: "transition target states must be non-empty strings." });
3216
+ }
3217
+ }
3218
+ }
3219
+ }
3131
3220
  function validateWorkflows(value, contextNames, capabilityNames, errors) {
3132
3221
  if (value === void 0)
3133
3222
  return;
@@ -3311,6 +3400,9 @@ function isQualifiedOrSafeName(value) {
3311
3400
  function isPositiveInteger2(value) {
3312
3401
  return Number.isInteger(value) && Number(value) > 0;
3313
3402
  }
3403
+ function isFiniteNumber2(value) {
3404
+ return typeof value === "number" && Number.isFinite(value);
3405
+ }
3314
3406
 
3315
3407
  // packages/spec/dist/normalize.js
3316
3408
  function normalizeContract(input) {
@@ -3428,6 +3520,7 @@ function runtimeCapabilityFromSpec(capability, resources, config) {
3428
3520
  name: capability.name,
3429
3521
  kind: capability.kind === "proposal" ? "proposal" : "read",
3430
3522
  ...capability.description ? { description: capability.description } : {},
3523
+ ...capability.returns_hint ? { returns_hint: capability.returns_hint } : {},
3431
3524
  source,
3432
3525
  context: capability.context,
3433
3526
  target: target2,
@@ -3440,6 +3533,8 @@ function runtimeCapabilityFromSpec(capability, resources, config) {
3440
3533
  if (capability.kind === "proposal" && capability.proposal) {
3441
3534
  runtime.patch = capability.proposal.patch;
3442
3535
  runtime.allowed_columns = capability.proposal.allowed_fields;
3536
+ runtime.numeric_bounds = capability.proposal.numeric_bounds;
3537
+ runtime.transition_guards = capability.proposal.transition_guards;
3443
3538
  runtime.conflict_guard = capability.proposal.conflict_guard;
3444
3539
  runtime.approval = capability.proposal.approval;
3445
3540
  runtime.writeback = {
@@ -6590,7 +6685,7 @@ function parseAgentDsl(source) {
6590
6685
  }
6591
6686
  return { contexts, capabilities, workflows };
6592
6687
  }
6593
- function compileAgentDsl(source) {
6688
+ function compileAgentDslWithWarnings(source) {
6594
6689
  const ast = parseAgentDsl(source);
6595
6690
  const contexts = ast.contexts.map((context) => ({
6596
6691
  name: context.name,
@@ -6602,6 +6697,8 @@ function compileAgentDsl(source) {
6602
6697
  const spec = {
6603
6698
  name: capability.name,
6604
6699
  kind: capability.kind,
6700
+ ...capability.description ? { description: capability.description } : {},
6701
+ ...capability.returnsHint ? { returns_hint: capability.returnsHint } : {},
6605
6702
  context: capability.context,
6606
6703
  ...capability.source ? { source: capability.source } : {},
6607
6704
  subject: {
@@ -6611,7 +6708,7 @@ function compileAgentDsl(source) {
6611
6708
  ...capability.tenantKey ? { tenant_key: capability.tenantKey } : {},
6612
6709
  ...capability.conflictKey ? { conflict_key: capability.conflictKey } : {}
6613
6710
  },
6614
- args: capability.args,
6711
+ args: specArgsFromDsl(capability.args),
6615
6712
  ...capability.lookup ? { lookup: { id_from_arg: capability.lookup.arg } } : {},
6616
6713
  visible_fields: capability.visibleFields,
6617
6714
  ...capability.keptOutFields.length ? { kept_out_fields: capability.keptOutFields } : {},
@@ -6623,6 +6720,8 @@ function compileAgentDsl(source) {
6623
6720
  action: capability.proposal.action,
6624
6721
  allowed_fields: capability.proposal.allowedFields,
6625
6722
  patch: capability.proposal.patch,
6723
+ ...capability.proposal.numericBounds ? { numeric_bounds: capability.proposal.numericBounds } : {},
6724
+ ...capability.proposal.transitionGuards ? { transition_guards: capability.proposal.transitionGuards } : {},
6626
6725
  conflict_guard: capability.conflictKey ? { column: capability.conflictKey } : { weak_guard_ack: true },
6627
6726
  approval: { mode: "human", required_role: capability.proposal.approvalRole ?? "local_reviewer" },
6628
6727
  writeback: {
@@ -6649,12 +6748,12 @@ function compileAgentDsl(source) {
6649
6748
  ...workflows.length ? { workflows } : {}
6650
6749
  };
6651
6750
  assertValidContract(contract);
6652
- return normalizeContract(contract);
6751
+ return { contract: normalizeContract(contract), warnings: collectDslWarnings(ast) };
6653
6752
  }
6654
6753
  function validateAgentDsl(source) {
6655
6754
  try {
6656
- compileAgentDsl(source);
6657
- return { ok: true, errors: [], warnings: [] };
6755
+ const result = compileAgentDslWithWarnings(source);
6756
+ return { ok: true, errors: [], warnings: result.warnings };
6658
6757
  } catch (error) {
6659
6758
  if (error instanceof AgentDslError) {
6660
6759
  return {
@@ -6739,6 +6838,7 @@ function parseContextBlock(block) {
6739
6838
  function parseCapabilityBlock(block) {
6740
6839
  const capability = {
6741
6840
  name: block.name,
6841
+ line: block.line,
6742
6842
  kind: "read",
6743
6843
  context: "",
6744
6844
  schema: "",
@@ -6749,6 +6849,16 @@ function parseCapabilityBlock(block) {
6749
6849
  keptOutFields: []
6750
6850
  };
6751
6851
  for (const item of block.body) {
6852
+ const description = item.text.match(/^DESCRIPTION\s+'(.*)'$/i);
6853
+ if (description) {
6854
+ capability.description = description[1] ?? "";
6855
+ continue;
6856
+ }
6857
+ const returnsHint = item.text.match(/^RETURNS\s+HINT\s+'(.*)'$/i);
6858
+ if (returnsHint) {
6859
+ capability.returnsHint = returnsHint[1] ?? "";
6860
+ continue;
6861
+ }
6752
6862
  const context = item.text.match(/^USING\s+CONTEXT\s+([A-Za-z_][A-Za-z0-9_.]*)$/i);
6753
6863
  if (context?.[1]) {
6754
6864
  capability.context = context[1];
@@ -6780,13 +6890,9 @@ function parseCapabilityBlock(block) {
6780
6890
  capability.conflictKey = conflict3[1];
6781
6891
  continue;
6782
6892
  }
6783
- const arg = item.text.match(/^ARG\s+([A-Za-z_][A-Za-z0-9_]*)\s+(STRING|TEXT|NUMBER|BOOLEAN|BOOL)(?:\s+REQUIRED)?(?:\s+MAX\s+(\d+))?$/i);
6893
+ const arg = item.text.match(/^ARG\s+([A-Za-z_][A-Za-z0-9_]*)\s+(STRING|TEXT|NUMBER|BOOLEAN|BOOL)\b(.*)$/i);
6784
6894
  if (arg?.[1] && arg[2]) {
6785
- capability.args[arg[1]] = {
6786
- type: normalizeArgType(arg[2]),
6787
- ...item.text.match(/\sREQUIRED(?:\s|$)/i) ? { required: true } : {},
6788
- ...arg[3] ? { max_length: Number(arg[3]) } : {}
6789
- };
6895
+ capability.args[arg[1]] = parseArgSpec(arg[1], arg[2], arg[3] ?? "", item.line);
6790
6896
  continue;
6791
6897
  }
6792
6898
  const lookup = item.text.match(/^LOOKUP\s+([A-Za-z_][A-Za-z0-9_]*)\s+BY\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
@@ -6836,6 +6942,30 @@ function parseCapabilityBlock(block) {
6836
6942
  capability.proposal.allowedFields.push(patch[1]);
6837
6943
  continue;
6838
6944
  }
6945
+ const bound = item.text.match(/^BOUND\s+([A-Za-z_][A-Za-z0-9_]*)\s+(-?\d+(?:\.\d+)?)?\s*\.\.\s*(-?\d+(?:\.\d+)?)?$/i);
6946
+ if (bound?.[1]) {
6947
+ ensureProposal(capability, item);
6948
+ const minimum = bound[2] !== void 0 ? Number(bound[2]) : void 0;
6949
+ const maximum = bound[3] !== void 0 ? Number(bound[3]) : void 0;
6950
+ if (minimum === void 0 && maximum === void 0)
6951
+ throw dslError(item.line, 1, "BOUND_RANGE_REQUIRED", "BOUND requires minimum, maximum, or both, such as 1..2500");
6952
+ capability.proposal.numericBounds ??= {};
6953
+ capability.proposal.numericBounds[bound[1]] = {
6954
+ ...minimum !== void 0 ? { minimum } : {},
6955
+ ...maximum !== void 0 ? { maximum } : {}
6956
+ };
6957
+ continue;
6958
+ }
6959
+ const transition = item.text.match(/^TRANSITION\s+([A-Za-z_][A-Za-z0-9_]*)(?:\s+FROM\s+([A-Za-z_][A-Za-z0-9_]*))?\s+ALLOW\s+(.+)$/i);
6960
+ if (transition?.[1] && transition[3]) {
6961
+ ensureProposal(capability, item);
6962
+ capability.proposal.transitionGuards ??= {};
6963
+ capability.proposal.transitionGuards[transition[1]] = {
6964
+ ...transition[2] ? { from_column: transition[2] } : {},
6965
+ allowed: parseTransitionAllowed(transition[3], item.line)
6966
+ };
6967
+ continue;
6968
+ }
6839
6969
  const approval = item.text.match(/^APPROVAL\s+ROLE\s+([A-Za-z_][A-Za-z0-9_.-]*)$/i);
6840
6970
  if (approval?.[1]) {
6841
6971
  ensureProposal(capability, item);
@@ -6868,6 +6998,40 @@ function parseCapabilityBlock(block) {
6868
6998
  throw dslError(block.line, 1, "PROPOSAL_PATCH_REQUIRED", `${block.name} proposal requires at least one PATCH line`);
6869
6999
  return capability;
6870
7000
  }
7001
+ function specArgsFromDsl(args) {
7002
+ return Object.fromEntries(Object.entries(args).map(([name, arg]) => {
7003
+ const { line: _line, ...spec } = arg;
7004
+ return [name, spec];
7005
+ }));
7006
+ }
7007
+ function collectDslWarnings(ast) {
7008
+ const warnings = [];
7009
+ for (const capability of ast.capabilities) {
7010
+ if (capability.kind !== "proposal" || !capability.proposal)
7011
+ continue;
7012
+ const line = capability.line ?? 1;
7013
+ if (!capability.description) {
7014
+ warnings.push({ line, column: 1, code: "DESCRIPTION_RECOMMENDED", message: `${capability.name} is a proposal capability without DESCRIPTION.` });
7015
+ }
7016
+ if (!capability.returnsHint) {
7017
+ warnings.push({ line, column: 1, code: "RETURNS_HINT_RECOMMENDED", message: `${capability.name} is a proposal capability without RETURNS HINT.` });
7018
+ }
7019
+ for (const [column, binding] of Object.entries(capability.proposal.patch)) {
7020
+ if (!binding.from_arg)
7021
+ continue;
7022
+ const arg = capability.args[binding.from_arg];
7023
+ if (arg?.type === "number" && arg.minimum === void 0 && arg.maximum === void 0 && capability.proposal.numericBounds?.[column] === void 0) {
7024
+ warnings.push({
7025
+ line: arg.line ?? line,
7026
+ column: 1,
7027
+ code: "NUMERIC_PATCH_BOUND_RECOMMENDED",
7028
+ message: `${capability.name} patches ${column} from numeric arg ${binding.from_arg} without ARG MIN/MAX or BOUND ${column}.`
7029
+ });
7030
+ }
7031
+ }
7032
+ }
7033
+ return warnings;
7034
+ }
6871
7035
  function parseWorkflowBlock(block) {
6872
7036
  const workflow = { name: block.name, context: "", allowedCapabilities: [] };
6873
7037
  for (const item of block.body) {
@@ -6925,6 +7089,76 @@ function parsePatchBinding(raw) {
6925
7089
  return { fixed: quoted[1] ?? "" };
6926
7090
  return { fixed: trimmed };
6927
7091
  }
7092
+ function parseArgSpec(name, rawType, rawOptions, line) {
7093
+ const type = normalizeArgType(rawType);
7094
+ let rest = rawOptions.trim();
7095
+ let description;
7096
+ const descriptionMatch = rest.match(/\bDESCRIPTION\s+'([^']*)'/i);
7097
+ if (descriptionMatch) {
7098
+ description = descriptionMatch[1] ?? "";
7099
+ rest = `${rest.slice(0, descriptionMatch.index)} ${rest.slice((descriptionMatch.index ?? 0) + descriptionMatch[0].length)}`.trim();
7100
+ }
7101
+ const required = /\bREQUIRED\b/i.test(rest);
7102
+ rest = rest.replace(/\bREQUIRED\b/ig, " ").trim();
7103
+ const maxLengthMatch = rest.match(/\bMAX\s+LENGTH\s+(\d+)\b/i);
7104
+ const maxLength = maxLengthMatch?.[1] ? Number(maxLengthMatch[1]) : void 0;
7105
+ if (maxLengthMatch)
7106
+ rest = `${rest.slice(0, maxLengthMatch.index)} ${rest.slice((maxLengthMatch.index ?? 0) + maxLengthMatch[0].length)}`.trim();
7107
+ const minMatch = rest.match(/\bMIN\s+(-?\d+(?:\.\d+)?)\b/i);
7108
+ const minimum = minMatch?.[1] ? Number(minMatch[1]) : void 0;
7109
+ if (minMatch)
7110
+ rest = `${rest.slice(0, minMatch.index)} ${rest.slice((minMatch.index ?? 0) + minMatch[0].length)}`.trim();
7111
+ const maxMatch = rest.match(/\bMAX\s+(-?\d+(?:\.\d+)?)\b/i);
7112
+ const maximumOrLegacyLength = maxMatch?.[1] ? Number(maxMatch[1]) : void 0;
7113
+ if (maxMatch)
7114
+ rest = `${rest.slice(0, maxMatch.index)} ${rest.slice((maxMatch.index ?? 0) + maxMatch[0].length)}`.trim();
7115
+ if (rest.trim().length > 0) {
7116
+ throw dslError(line, 1, "ARG_OPTIONS_UNSUPPORTED", `unsupported ARG options for ${name}: ${rest.trim()}`);
7117
+ }
7118
+ if (type !== "number" && minimum !== void 0) {
7119
+ throw dslError(line, 1, "ARG_MIN_REQUIRES_NUMBER", `ARG ${name} uses MIN, but MIN is only valid for NUMBER args`);
7120
+ }
7121
+ if (type === "boolean" && (maxLength !== void 0 || maximumOrLegacyLength !== void 0)) {
7122
+ throw dslError(line, 1, "ARG_MAX_INVALID_FOR_BOOLEAN", `ARG ${name} cannot use MAX or MAX LENGTH because BOOLEAN has no numeric or length bound`);
7123
+ }
7124
+ if (type === "number" && maxLength !== void 0) {
7125
+ throw dslError(line, 1, "ARG_MAX_LENGTH_REQUIRES_TEXT", `ARG ${name} uses MAX LENGTH, but MAX LENGTH is only valid for STRING/TEXT args`);
7126
+ }
7127
+ return {
7128
+ type,
7129
+ line,
7130
+ ...required ? { required: true } : {},
7131
+ ...description !== void 0 ? { description } : {},
7132
+ ...type === "number" && minimum !== void 0 ? { minimum } : {},
7133
+ ...type === "number" && maximumOrLegacyLength !== void 0 ? { maximum: maximumOrLegacyLength } : {},
7134
+ ...type !== "number" && maxLength !== void 0 ? { max_length: maxLength } : {},
7135
+ ...type !== "number" && maxLength === void 0 && maximumOrLegacyLength !== void 0 ? { max_length: maximumOrLegacyLength } : {}
7136
+ };
7137
+ }
7138
+ function parseTransitionAllowed(raw, line) {
7139
+ const allowed = {};
7140
+ for (const item of raw.split(",").map((part) => part.trim()).filter(Boolean)) {
7141
+ const match = item.match(/^(.+?)\s*->\s*(.+)$/);
7142
+ if (!match?.[1] || !match[2]) {
7143
+ throw dslError(line, 1, "TRANSITION_RULE_INVALID", `transition rule must use from -> to syntax: ${item}`);
7144
+ }
7145
+ const from = parseStateValue(match[1]);
7146
+ const toValues = match[2].split("|").map(parseStateValue).filter(Boolean);
7147
+ if (!from || toValues.length === 0) {
7148
+ throw dslError(line, 1, "TRANSITION_RULE_INVALID", `transition rule must name non-empty states: ${item}`);
7149
+ }
7150
+ allowed[from] = toValues;
7151
+ }
7152
+ if (Object.keys(allowed).length === 0) {
7153
+ throw dslError(line, 1, "TRANSITION_ALLOWED_REQUIRED", "TRANSITION requires at least one from -> to rule");
7154
+ }
7155
+ return allowed;
7156
+ }
7157
+ function parseStateValue(raw) {
7158
+ const trimmed = raw.trim();
7159
+ const quoted = trimmed.match(/^'(.*)'$/);
7160
+ return (quoted?.[1] ?? trimmed).trim();
7161
+ }
6928
7162
  function normalizeBindingSource(source) {
6929
7163
  const normalized = source.toUpperCase();
6930
7164
  if (normalized === "ENV")
@@ -8145,6 +8379,11 @@ async function main(argv) {
8145
8379
  usage([]);
8146
8380
  return 0;
8147
8381
  }
8382
+ if (command === "--version" || command === "-v" || command === "version") {
8383
+ process2.stdout.write(`${await runnerPackageVersion()}
8384
+ `);
8385
+ return 0;
8386
+ }
8148
8387
  if (!isKnownTopLevelCommand(command)) {
8149
8388
  process2.stderr.write(`Unknown command: ${cliCommandName()} ${command}
8150
8389
 
@@ -9331,12 +9570,15 @@ async function dslValidate(args) {
9331
9570
  const target2 = firstPositional(args);
9332
9571
  if (!target2) throw new Error("dsl validate requires <contract.synapsor>");
9333
9572
  const source = await fs3.readFile(target2, "utf8");
9573
+ const strict = args.includes("--strict");
9334
9574
  const result = validateAgentDsl(source);
9335
9575
  if (args.includes("--json")) {
9336
9576
  process2.stdout.write(`${JSON.stringify(result, null, 2)}
9337
9577
  `);
9338
9578
  } else if (result.ok) {
9339
9579
  process2.stdout.write(`dsl valid: ${target2}
9580
+ `);
9581
+ for (const warning of result.warnings) process2.stdout.write(`warning ${warning.line}:${warning.column} ${warning.code}: ${warning.message}
9340
9582
  `);
9341
9583
  } else {
9342
9584
  process2.stdout.write(`dsl invalid: ${target2}
@@ -9344,13 +9586,22 @@ async function dslValidate(args) {
9344
9586
  for (const error of result.errors) process2.stdout.write(`error ${error.line}:${error.column} ${error.code}: ${error.message}
9345
9587
  `);
9346
9588
  }
9347
- return result.ok ? 0 : 1;
9589
+ return result.ok && (!strict || result.warnings.length === 0) ? 0 : 1;
9348
9590
  }
9349
9591
  async function dslCompile(args) {
9350
9592
  const target2 = firstPositional(args);
9351
9593
  if (!target2) throw new Error("dsl compile requires <contract.synapsor>");
9352
9594
  const source = await fs3.readFile(target2, "utf8");
9353
- const contract = compileAgentDsl(source);
9595
+ const strict = args.includes("--strict");
9596
+ const result = compileAgentDslWithWarnings(source);
9597
+ if (strict && result.warnings.length > 0) {
9598
+ process2.stdout.write(`dsl warnings treated as errors: ${target2}
9599
+ `);
9600
+ for (const warning of result.warnings) process2.stdout.write(`warning ${warning.line}:${warning.column} ${warning.code}: ${warning.message}
9601
+ `);
9602
+ return 1;
9603
+ }
9604
+ const contract = result.contract;
9354
9605
  const output = outputArg(args);
9355
9606
  const text = `${JSON.stringify(contract, null, 2)}
9356
9607
  `;
@@ -9361,6 +9612,8 @@ async function dslCompile(args) {
9361
9612
  } else {
9362
9613
  process2.stdout.write(text);
9363
9614
  }
9615
+ for (const warning of result.warnings) process2.stderr.write(`warning ${warning.line}:${warning.column} ${warning.code}: ${warning.message}
9616
+ `);
9364
9617
  return 0;
9365
9618
  }
9366
9619
  async function contractValidate(args) {
@@ -11291,19 +11544,33 @@ async function cloudPush(args) {
11291
11544
  if (!target2) throw new Error("cloud push requires <synapsor.contract.json>");
11292
11545
  const parsed = JSON.parse(await fs3.readFile(target2, "utf8"));
11293
11546
  const contract = normalizeContract(parsed);
11547
+ const workspace = (optionalArg(args, "--workspace") ?? optionalArg(args, "--project") ?? process2.env.SYNAPSOR_WORKSPACE_ID ?? process2.env.SYNAPSOR_PROJECT_ID ?? "").trim();
11548
+ const name = (optionalArg(args, "--name") ?? contract.metadata?.name ?? "").trim();
11549
+ const description = (optionalArg(args, "--description") ?? contract.metadata?.description ?? "").trim();
11550
+ const idempotencyKey = optionalArg(args, "--idempotency-key");
11294
11551
  const payload = {
11295
11552
  schema_version: "synapsor.cloud-contract-push.v0.1",
11296
11553
  contract,
11297
11554
  summary: contractSummary(contract),
11298
- workspace: optionalArg(args, "--workspace") ?? process2.env.SYNAPSOR_WORKSPACE_ID,
11299
- name: optionalArg(args, "--name") ?? contract.metadata?.name,
11555
+ workspace,
11556
+ name,
11557
+ description,
11558
+ source: "runner",
11559
+ source_versions: {
11560
+ "@synapsor/runner": process2.env.npm_package_version ?? "unknown"
11561
+ },
11562
+ activate: args.includes("--activate"),
11563
+ idempotency_key: idempotencyKey,
11300
11564
  pushed_at: (/* @__PURE__ */ new Date()).toISOString()
11301
11565
  };
11302
11566
  const dryRun = args.includes("--dry-run");
11303
- if (args.includes("--json")) {
11567
+ const json = args.includes("--json");
11568
+ if (dryRun && json) {
11304
11569
  process2.stdout.write(`${JSON.stringify({ ok: dryRun, dry_run: dryRun, payload }, null, 2)}
11305
11570
  `);
11306
- } else {
11571
+ return 0;
11572
+ }
11573
+ if (dryRun) {
11307
11574
  process2.stdout.write("Synapsor Cloud contract push preview\n");
11308
11575
  process2.stdout.write(`Contract: ${target2}
11309
11576
  `);
@@ -11317,17 +11584,48 @@ async function cloudPush(args) {
11317
11584
  `);
11318
11585
  process2.stdout.write(`Kept-out fields: ${payload.summary.kept_out_fields}
11319
11586
  `);
11320
- }
11321
- if (dryRun) {
11322
- if (!args.includes("--json")) process2.stdout.write("Dry run only. No Cloud upload attempted.\n");
11587
+ process2.stdout.write("Dry run only. No Cloud upload attempted.\n");
11323
11588
  return 0;
11324
11589
  }
11325
- const apiUrl = optionalArg(args, "--api-url") ?? process2.env.SYNAPSOR_CLOUD_BASE_URL;
11326
- const token = optionalArg(args, "--token") ?? process2.env.SYNAPSOR_CLOUD_TOKEN ?? process2.env.SYNAPSOR_RUNNER_TOKEN;
11590
+ const apiUrl = (optionalArg(args, "--api-url") ?? process2.env.SYNAPSOR_CLOUD_BASE_URL ?? "").trim();
11591
+ const token = (optionalArg(args, "--token") ?? process2.env.SYNAPSOR_CLOUD_TOKEN ?? process2.env.SYNAPSOR_RUNNER_TOKEN ?? "").trim();
11592
+ if (!workspace) {
11593
+ throw new Error("cloud push upload requires --workspace <project_id> or SYNAPSOR_WORKSPACE_ID/SYNAPSOR_PROJECT_ID.");
11594
+ }
11327
11595
  if (!apiUrl || !token) {
11328
- throw new Error("cloud push upload requires --dry-run, or real --api-url plus --token/SYNAPSOR_CLOUD_TOKEN after the Cloud registry endpoint is available.");
11596
+ throw new Error("cloud push upload requires --api-url plus --token/SYNAPSOR_CLOUD_TOKEN. Use --dry-run for local validation without a network call.");
11597
+ }
11598
+ const response = await postCloudContractPush({
11599
+ apiUrl,
11600
+ token,
11601
+ workspace,
11602
+ payload,
11603
+ idempotencyKey
11604
+ });
11605
+ if (json) {
11606
+ process2.stdout.write(`${JSON.stringify(response, null, 2)}
11607
+ `);
11608
+ return 0;
11329
11609
  }
11330
- throw new Error("cloud push upload is not wired to a Cloud registry endpoint yet. Use --dry-run for local validation; do not treat this as uploaded.");
11610
+ const contractId = cloudStringField(response, "contract_id") || cloudStringField(response.contract, "contract_id");
11611
+ const versionId = cloudStringField(response, "contract_version_id") || cloudStringField(response.version, "contract_version_id");
11612
+ const digest = cloudStringField(response, "digest") || cloudStringField(response.version, "digest");
11613
+ const status = cloudStringField(response, "status") || cloudStringField(response.version, "status") || "stored";
11614
+ process2.stdout.write("Synapsor Cloud contract push complete\n");
11615
+ process2.stdout.write(`Workspace: ${workspace}
11616
+ `);
11617
+ if (contractId) process2.stdout.write(`Contract id: ${contractId}
11618
+ `);
11619
+ if (versionId) process2.stdout.write(`Version id: ${versionId}
11620
+ `);
11621
+ if (digest) process2.stdout.write(`Digest: ${digest}
11622
+ `);
11623
+ process2.stdout.write(`Status: ${status}
11624
+ `);
11625
+ const registryUrl = cloudStringField(response, "registry_url");
11626
+ if (registryUrl) process2.stdout.write(`Registry: ${registryUrl}
11627
+ `);
11628
+ return 0;
11331
11629
  }
11332
11630
  function contractSummary(contract) {
11333
11631
  return {
@@ -11338,6 +11636,81 @@ function contractSummary(contract) {
11338
11636
  kept_out_fields: contract.capabilities.reduce((count, capability) => count + (capability.kept_out_fields?.length ?? 0), 0)
11339
11637
  };
11340
11638
  }
11639
+ async function postCloudContractPush(input) {
11640
+ let endpoint;
11641
+ try {
11642
+ endpoint = new URL(`/v1/control/projects/${encodeURIComponent(input.workspace)}/agent-contracts`, input.apiUrl.endsWith("/") ? input.apiUrl : `${input.apiUrl}/`);
11643
+ } catch {
11644
+ throw new Error("cloud push upload failed: --api-url/SYNAPSOR_CLOUD_BASE_URL is not a valid URL.");
11645
+ }
11646
+ const headers = {
11647
+ accept: "application/json",
11648
+ authorization: `Bearer ${input.token}`,
11649
+ "content-type": "application/json",
11650
+ "user-agent": "synapsor-runner-cloud-push"
11651
+ };
11652
+ if (input.idempotencyKey) headers["idempotency-key"] = input.idempotencyKey;
11653
+ const controller = new AbortController();
11654
+ const timeout = setTimeout(() => controller.abort(), 15e3);
11655
+ let response;
11656
+ try {
11657
+ response = await fetch(endpoint, {
11658
+ method: "POST",
11659
+ headers,
11660
+ body: JSON.stringify(input.payload),
11661
+ signal: controller.signal
11662
+ });
11663
+ } catch (error) {
11664
+ const reason = error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError") ? "request timed out" : "network request failed";
11665
+ throw new Error(`cloud push upload failed: ${reason}. Check --api-url/SYNAPSOR_CLOUD_BASE_URL and network connectivity.`);
11666
+ } finally {
11667
+ clearTimeout(timeout);
11668
+ }
11669
+ const text = await response.text();
11670
+ const body = parseCloudResponseJson(text);
11671
+ if (!response.ok || body.ok === false) {
11672
+ throw new Error(cloudPushErrorMessage(response.status, body));
11673
+ }
11674
+ return body;
11675
+ }
11676
+ function parseCloudResponseJson(text) {
11677
+ if (!text.trim()) return {};
11678
+ try {
11679
+ const parsed = JSON.parse(text);
11680
+ return isRecord7(parsed) ? parsed : { value: parsed };
11681
+ } catch {
11682
+ return { raw: text.slice(0, 500) };
11683
+ }
11684
+ }
11685
+ function cloudPushErrorMessage(status, body) {
11686
+ const code = typeof body.error === "string" && body.error ? body.error : `http_${status}`;
11687
+ const detail = cloudValidationDetail(body);
11688
+ if (status === 401) return `cloud push upload failed: token is missing, invalid, or expired (${code}).`;
11689
+ if (status === 403) return `cloud push upload failed: token does not have permission to write this workspace (${code}).`;
11690
+ if (status === 404) return `cloud push upload failed: Cloud API URL or workspace was not found (${code}).`;
11691
+ if (status === 409) return `cloud push upload failed: registry conflict (${code}).${detail}`;
11692
+ if (status === 422) return `cloud push upload failed: Cloud rejected the contract (${code}).${detail}`;
11693
+ if (status >= 500) return `cloud push upload failed: Cloud returned HTTP ${status} (${code}).`;
11694
+ return `cloud push upload failed: Cloud returned HTTP ${status} (${code}).${detail}`;
11695
+ }
11696
+ function cloudValidationDetail(body) {
11697
+ const errors = Array.isArray(body.errors) ? body.errors : [];
11698
+ if (!errors.length) {
11699
+ const message = typeof body.message === "string" ? body.message : "";
11700
+ return message ? ` ${message}` : "";
11701
+ }
11702
+ const rendered = errors.slice(0, 3).map((issue) => {
11703
+ if (!isRecord7(issue)) return String(issue);
11704
+ const path4 = typeof issue.path === "string" ? issue.path : "$";
11705
+ const code = typeof issue.code === "string" ? issue.code : "validation_error";
11706
+ const message = typeof issue.message === "string" ? issue.message : "";
11707
+ return `${path4} ${code}${message ? `: ${message}` : ""}`;
11708
+ }).join("; ");
11709
+ return ` ${rendered}`;
11710
+ }
11711
+ function cloudStringField(value, key) {
11712
+ return isRecord7(value) && typeof value[key] === "string" ? value[key] : "";
11713
+ }
11341
11714
  async function cloudConnect(args) {
11342
11715
  const configPath = optionalArg(args, "--config") ?? process2.env.SYNAPSOR_MCP_CONFIG ?? "synapsor.cloud.json";
11343
11716
  const parsed = JSON.parse(await fs3.readFile(configPath, "utf8"));
@@ -14648,6 +15021,7 @@ function firstPositional(args) {
14648
15021
  const flagsWithValues = /* @__PURE__ */ new Set([
14649
15022
  "--allowed-columns",
14650
15023
  "--approval-role",
15024
+ "--api-url",
14651
15025
  "--actor",
14652
15026
  "--action",
14653
15027
  "--auth-token-env",
@@ -14658,6 +15032,7 @@ function firstPositional(args) {
14658
15032
  "--conflict-column",
14659
15033
  "--cors-origin",
14660
15034
  "--database-url-env",
15035
+ "--description",
14661
15036
  "--destination",
14662
15037
  "--engine",
14663
15038
  "--evidence",
@@ -14713,10 +15088,12 @@ function firstPositional(args) {
14713
15088
  "--tenant-env",
14714
15089
  "--tenant-key",
14715
15090
  "--timeout-ms",
15091
+ "--token",
14716
15092
  "--to",
14717
15093
  "--transition-guard",
14718
15094
  "--url",
14719
15095
  "--visible-columns",
15096
+ "--workspace",
14720
15097
  "--writeback-job",
14721
15098
  "--write-url-env"
14722
15099
  ]);
@@ -15733,6 +16110,16 @@ function cliCommandName() {
15733
16110
  if (process2.env.SYNAPSOR_RUNNER_COMMAND_NAME) return process2.env.SYNAPSOR_RUNNER_COMMAND_NAME;
15734
16111
  return "synapsor-runner";
15735
16112
  }
16113
+ async function runnerPackageVersion() {
16114
+ if (process2.env.npm_package_version) return process2.env.npm_package_version;
16115
+ const packageUrl = new URL("../package.json", import.meta.url);
16116
+ try {
16117
+ const parsed = JSON.parse(await fs3.readFile(packageUrl, "utf8"));
16118
+ if (typeof parsed.version === "string" && parsed.version.trim()) return parsed.version.trim();
16119
+ } catch {
16120
+ }
16121
+ return "unknown";
16122
+ }
15736
16123
  function usage(args = []) {
15737
16124
  const [command, subcommand] = args;
15738
16125
  const key = (command === "mcp" || command === "handler") && subcommand ? `${command} ${subcommand}` : command ?? "";
@@ -26,6 +26,8 @@ CREATE AGENT CONTEXT local_operator
26
26
  END
27
27
 
28
28
  CREATE CAPABILITY billing.inspect_invoice
29
+ DESCRIPTION 'Inspect one invoice in the trusted tenant before proposing a waiver.'
30
+ RETURNS HINT 'Returns reviewed invoice fields plus evidence/query-audit handles.'
29
31
  USING CONTEXT local_operator
30
32
  SOURCE local_postgres
31
33
  ON public.invoices
@@ -33,13 +35,15 @@ CREATE CAPABILITY billing.inspect_invoice
33
35
  TENANT KEY tenant_id
34
36
  CONFLICT GUARD updated_at
35
37
  LOOKUP invoice_id BY id
36
- ARG invoice_id STRING REQUIRED MAX 128
38
+ ARG invoice_id STRING REQUIRED MAX LENGTH 128 DESCRIPTION 'Invoice id such as INV-3001.'
37
39
  ALLOW READ id, tenant_id, status, late_fee_cents, updated_at
38
40
  REQUIRE EVIDENCE
39
41
  MAX ROWS 1
40
42
  END
41
43
 
42
44
  CREATE CAPABILITY billing.propose_late_fee_waiver
45
+ DESCRIPTION 'Propose waiving one invoice late fee after inspecting invoice and policy evidence.'
46
+ RETURNS HINT 'Returns a review-required proposal id, exact diff, evidence handle, and source_database_changed:false.'
43
47
  USING CONTEXT local_operator
44
48
  SOURCE local_postgres
45
49
  ON public.invoices
@@ -47,8 +51,8 @@ CREATE CAPABILITY billing.propose_late_fee_waiver
47
51
  TENANT KEY tenant_id
48
52
  CONFLICT GUARD updated_at
49
53
  LOOKUP invoice_id BY id
50
- ARG invoice_id STRING REQUIRED MAX 128
51
- ARG reason TEXT REQUIRED MAX 500
54
+ ARG invoice_id STRING REQUIRED MAX LENGTH 128 DESCRIPTION 'Invoice id such as INV-3001.'
55
+ ARG reason TEXT REQUIRED MAX LENGTH 500 DESCRIPTION 'Business reason for the proposed waiver.'
52
56
  ALLOW READ id, tenant_id, status, late_fee_cents, waiver_reason, updated_at
53
57
  REQUIRE EVIDENCE
54
58
  MAX ROWS 1
@@ -56,6 +60,7 @@ CREATE CAPABILITY billing.propose_late_fee_waiver
56
60
  ALLOW WRITE late_fee_cents, waiver_reason
57
61
  PATCH late_fee_cents = 0
58
62
  PATCH waiver_reason = ARG reason
63
+ BOUND late_fee_cents 0..10000
59
64
  APPROVAL ROLE billing_lead
60
65
  WRITEBACK DIRECT SQL
61
66
  END
@@ -64,10 +69,15 @@ END
64
69
  Compile and validate:
65
70
 
66
71
  ```bash
67
- synapsor-runner dsl compile ./contract.synapsor --out ./synapsor.contract.json
72
+ synapsor-runner dsl compile ./contract.synapsor --out ./synapsor.contract.json --strict
68
73
  synapsor-runner contract validate ./synapsor.contract.json
69
74
  synapsor-runner contract bundle ./synapsor.contract.json --out ./synapsor-runner-bundle
70
75
  synapsor-runner cloud push ./synapsor.contract.json --dry-run
76
+ synapsor-runner cloud push ./synapsor.contract.json \
77
+ --api-url "$SYNAPSOR_CLOUD_BASE_URL" \
78
+ --token "$SYNAPSOR_CLOUD_TOKEN" \
79
+ --workspace "$SYNAPSOR_PROJECT_ID" \
80
+ --name billing-late-fee
71
81
  ```
72
82
 
73
83
  Reference the generated contract from local runner wiring:
@@ -95,6 +105,31 @@ synapsor-runner tools preview --config ./synapsor.runner.json --store ./.synapso
95
105
  synapsor-runner mcp serve --config ./synapsor.runner.json --store ./.synapsor/local.db
96
106
  ```
97
107
 
108
+ `--strict` treats DSL safety warnings as errors. Use it in CI so proposal
109
+ capabilities cannot accidentally lose descriptions, returns hints, or numeric
110
+ patch bounds during review.
111
+
112
+ ## DSL / JSON Capability Parity
113
+
114
+ The DSL compiles to canonical `@synapsor/spec` JSON. It must not silently weaken
115
+ reviewed runner JSON capabilities. Current parity:
116
+
117
+ | JSON field | DSL clause | Since | Notes |
118
+ | --- | --- | --- | --- |
119
+ | capability `description` | `DESCRIPTION '...'` | 0.1.8 | Recommended for every model-facing tool; strict mode warns when proposal capabilities omit it. |
120
+ | capability `returns_hint` | `RETURNS HINT '...'` | 0.1.8 | Recommended for every model-facing tool; strict mode warns when proposal capabilities omit it. |
121
+ | arg `description` | `ARG name TYPE ... DESCRIPTION '...'` | 0.1.8 | Used in MCP tool input schemas. |
122
+ | arg `minimum` | `ARG amount NUMBER MIN 1` | 0.1.8 | NUMBER args only. |
123
+ | arg `maximum` | `ARG amount NUMBER MAX 2500` | 0.1.8 | NUMBER args only. |
124
+ | arg `max_length` | `ARG reason TEXT MAX LENGTH 500` | 0.1.8 | STRING/TEXT args only. Legacy `MAX 500` is still accepted for existing DSL files, but `MAX LENGTH` is the reviewed spelling. |
125
+ | arg `enum` | Not expressible in DSL yet | 0.1 | Use embedded JSON or generated contract JSON when enum allowlists are required. |
126
+ | proposal `numeric_bounds` | `BOUND column 1..2500`, `BOUND column ..2500`, or `BOUND column 1..` | 0.1.8 | Applies to patched numeric columns. Strict mode warns when a NUMBER arg is patched without arg min/max or a matching `BOUND`. |
127
+ | proposal `transition_guards` | `TRANSITION status ALLOW pending -> approved\|rejected` or `TRANSITION status FROM current_status ALLOW open -> closed` | 0.1.8 | Values are state strings; use `|` for multiple target states. |
128
+ | proposal `conflict_guard` | `CONFLICT GUARD updated_at` | 0.1 | If omitted, DSL emits an explicit weak-guard acknowledgement. Prefer a real row-version column. |
129
+ | proposal `approval` | `APPROVAL ROLE billing_lead` | 0.1 | Local mode records the required role; enforcement is still outside the model-facing MCP tool. |
130
+ | proposal `writeback` | `WRITEBACK DIRECT SQL`, `WRITEBACK APP HANDLER EXECUTOR name`, `WRITEBACK CLOUD WORKER`, `WRITEBACK NONE` | 0.1.7 | Handler URLs/tokens stay in `synapsor.runner.json`; contracts carry only the handler name. |
131
+ | evidence options | `REQUIRE EVIDENCE` | 0.1 | Detailed evidence sources/handle prefixes are not expressible in DSL yet; use embedded JSON or generated contract JSON for those. |
132
+
98
133
  ## Direct Runner Config Path
99
134
 
100
135
  Directly embedding capabilities in `synapsor.runner.json` remains supported for
@@ -26,6 +26,17 @@ The fixture set is intentionally small in 0.1. It covers the runner-supported
26
26
  semantic surface first: trusted context, scoped reads, evidence handles,
27
27
  proposal boundaries, kept-out fields, manual approval, and replay envelopes.
28
28
 
29
+ Additional 0.1 parity coverage currently lives in tests and verification
30
+ scripts rather than separate `cloud-push/` or `dsl-json-parity/` conformance
31
+ fixture directories:
32
+
33
+ - `docs/dsl-json-parity.md`, DSL/spec tests, and the
34
+ `numeric-bounds-transition` C++ export fixture cover richer DSL/JSON parity
35
+ fields such as `returns_hint`, numeric bounds, and transition guards.
36
+ - The main Synapsor repo script `scripts/verify_contract_cloud_push.sh`
37
+ verifies real Cloud push, retrieval, idempotent versioning, unauthorized
38
+ rejection, and runner-bundle download against a live local control-plane.
39
+
29
40
  ## Runner Usage
30
41
 
31
42
  Runner tests load every conformance `contract.json` and verify that the MCP
@@ -351,6 +351,28 @@ It launches the official MCP stdio client transport against `synapsor-runner mcp
351
351
  The business state changed after the agent saw it, so Synapsor refused to commit.
352
352
  ```
353
353
 
354
+ The same live writeback smoke has a clearer release-check alias:
355
+
356
+ ```bash
357
+ corepack pnpm test:live-apply
358
+ ```
359
+
360
+ Prerequisites:
361
+
362
+ - Docker daemon running;
363
+ - local ports `55433`, `55434`, and `53307` available;
364
+ - no Synapsor Cloud account, API key, hosted workspace, or production database.
365
+
366
+ Expected output:
367
+
368
+ - disposable Postgres billing, Postgres support, and MySQL orders containers start;
369
+ - MCP `tools/list` exposes reviewed semantic tools, not raw SQL;
370
+ - proposal calls create exact before/after diffs while source rows remain unchanged;
371
+ - local approval happens outside MCP;
372
+ - guarded writeback applies approved jobs and records receipts/replay;
373
+ - idempotent retry returns the existing receipt;
374
+ - a stale-row write is blocked as a conflict.
375
+
354
376
  ## Optional MCP client configs
355
377
 
356
378
  After the Docker demo passes, developers who want to attach an MCP client can use the checked-in stdio config shapes in:
@@ -139,7 +139,7 @@ You can also compile from the SQL-like authoring layer:
139
139
 
140
140
  ```bash
141
141
  synapsor-runner dsl validate ./contract.synapsor
142
- synapsor-runner dsl compile ./contract.synapsor --out ./synapsor.contract.json
142
+ synapsor-runner dsl compile ./contract.synapsor --out ./synapsor.contract.json --strict
143
143
  ```
144
144
 
145
145
  ## Bundle For Local Runner
@@ -10,6 +10,45 @@ npx -y -p @synapsor/runner synapsor-runner demo --quick
10
10
  The OSS runner command is `synapsor-runner`. The `synapsor` command is reserved
11
11
  for the Synapsor Cloud CLI.
12
12
 
13
+ ## Unreleased
14
+
15
+ ## 0.1.9
16
+
17
+ ### CLI Hygiene
18
+
19
+ - Adds top-level `synapsor-runner --version`, `synapsor-runner -v`, and
20
+ `synapsor-runner version` output.
21
+
22
+ ## 0.1.8
23
+
24
+ ### DSL / JSON Contract Parity
25
+
26
+ - Adds portable spec fields for capability `returns_hint`, proposal
27
+ `numeric_bounds`, and proposal `transition_guards`.
28
+ - Extends the DSL with `DESCRIPTION`, `RETURNS HINT`, arg descriptions,
29
+ numeric arg min/max, text `MAX LENGTH`, patch `BOUND`, and `TRANSITION`
30
+ clauses.
31
+ - Adds DSL warnings and `--strict` mode so proposal capabilities cannot
32
+ silently lose reviewed safety metadata.
33
+ - Preserves compiled bounds through `contracts: []` into Runner propose-time
34
+ enforcement and accepts pure-contract configs with `capabilities: []`.
35
+ - Adds `docs/dsl-json-parity.md` as the field-by-field support matrix across
36
+ JSON spec, DSL, Runner, C++/Cloud, and Cloud push.
37
+
38
+ ### Cloud Registry Push
39
+
40
+ - Wires non-dry-run `synapsor-runner cloud push` to the Cloud control API.
41
+ - Keeps dry-run network-free and prints server-confirmed contract, version,
42
+ digest, and registry details for real uploads.
43
+ - Adds clearer 401/403/404/409/422/network error messages without printing
44
+ bearer tokens.
45
+
46
+ ### Release Verification
47
+
48
+ - Adds `corepack pnpm test:live-apply` as the documented Docker-backed live
49
+ apply smoke for disposable Postgres/MySQL MCP examples, guarded writeback,
50
+ idempotent retry, stale-row conflict, receipts, and replay.
51
+
13
52
  ## 0.1.5
14
53
 
15
54
  ### Contract Authoring Front Door
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapsor/runner",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",