@synapsor/runner 1.4.122 → 1.4.123

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
@@ -1,6 +1,22 @@
1
1
  # Changelog
2
2
 
3
- ## 1.4.122 (prepared, not published)
3
+ ## 1.4.123 (prepared, not published)
4
+
5
+ ### Advisory capability-surface fitness lint
6
+
7
+ - Adds deterministic `contract lint` advisories for generic query-like string
8
+ arguments, more than eight capabilities on one normalized target,
9
+ non-business-operation names, and structurally near-duplicate capabilities.
10
+ - Keeps canonical validation, compilation, MCP serving, and runtime enforcement
11
+ unchanged. Advisories succeed by default; explicit `--strict` or
12
+ `--fail-on warning` remains the opt-in CI policy gate.
13
+ - Adds stable structured details and surface metrics to JSON/SARIF output, plus
14
+ a concise text summary. No database connection, environment value, source
15
+ row, or probabilistic classifier is involved.
16
+ - Stages only `@synapsor/runner@1.4.123`; `@synapsor/spec@1.4.2`,
17
+ `@synapsor/dsl@1.4.3`, and `@synapsor/cli@0.1.0-beta.1` are unchanged.
18
+
19
+ ## 1.4.122 (2026-07-16)
4
20
 
5
21
  ### Trusted principal row scope and Cloud-linked governance
6
22
 
@@ -23,8 +39,8 @@
23
39
  Runner-token fallback.
24
40
  - Introduces the separately packable `@synapsor/cli@0.1.0-beta.1` Cloud client;
25
41
  `synapsor-runner` remains the local MCP/database enforcement boundary.
26
- - Stages `@synapsor/spec@1.4.2`, `@synapsor/dsl@1.4.3`, and
27
- `@synapsor/runner@1.4.122`. Nothing is published by this change.
42
+ - Published `@synapsor/spec@1.4.2`, `@synapsor/dsl@1.4.3`, and
43
+ `@synapsor/runner@1.4.122`.
28
44
 
29
45
  ## 1.4.121 (prepared, not published)
30
46
 
package/README.md CHANGED
@@ -166,8 +166,8 @@ compensation.
166
166
  ## Review And Prove Your Contract
167
167
 
168
168
  Before serving a contract, use `contract explain` for a reviewer-readable
169
- boundary, `contract lint --strict` for deterministic CI checks, and `contract
170
- test` for adopter-owned allow/deny/redaction cases. The built-in language
169
+ boundary, `contract lint --strict` for deterministic surface-fitness CI checks,
170
+ and `contract test` for adopter-owned allow/deny/redaction cases. The built-in language
171
171
  server supplies diagnostics, completion, hover, and formatting for
172
172
  `.synapsor.sql` and legacy `.synapsor` files. See [Contract
173
173
  Review](docs/contract-review.md) and [Contract
package/dist/runner.mjs CHANGED
@@ -41376,6 +41376,299 @@ function isRecord7(value) {
41376
41376
  // apps/runner/src/contract-tools.ts
41377
41377
  import fs5 from "node:fs/promises";
41378
41378
  import path5 from "node:path";
41379
+
41380
+ // apps/runner/src/capability-surface-lint.ts
41381
+ var CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD = 8;
41382
+ var GENERIC_ARGUMENT_NAMES = /* @__PURE__ */ new Set(["filter", "predicate", "query", "sql", "where"]);
41383
+ var ACTION_PREFIXES = /* @__PURE__ */ new Set([
41384
+ "add",
41385
+ "answer",
41386
+ "archive",
41387
+ "assign",
41388
+ "cancel",
41389
+ "change",
41390
+ "close",
41391
+ "count",
41392
+ "create",
41393
+ "delete",
41394
+ "export",
41395
+ "find",
41396
+ "get",
41397
+ "grant",
41398
+ "inspect",
41399
+ "list",
41400
+ "lookup",
41401
+ "open",
41402
+ "propose",
41403
+ "read",
41404
+ "refund",
41405
+ "remove",
41406
+ "request",
41407
+ "resolve",
41408
+ "restore",
41409
+ "revert",
41410
+ "search",
41411
+ "set",
41412
+ "sum",
41413
+ "update",
41414
+ "verify",
41415
+ "waive",
41416
+ "write"
41417
+ ]);
41418
+ var ACTION_SUFFIXES = /* @__PURE__ */ new Set(["average", "count", "lookup", "review", "search", "summary", "total"]);
41419
+ var GENERIC_OPERATION_WORDS = /* @__PURE__ */ new Set([
41420
+ "create",
41421
+ "data",
41422
+ "database",
41423
+ "db",
41424
+ "delete",
41425
+ "execute",
41426
+ "get",
41427
+ "item",
41428
+ "manage",
41429
+ "mutate",
41430
+ "object",
41431
+ "process",
41432
+ "query",
41433
+ "read",
41434
+ "record",
41435
+ "resource",
41436
+ "row",
41437
+ "run",
41438
+ "set",
41439
+ "sql",
41440
+ "table",
41441
+ "thing",
41442
+ "update",
41443
+ "write"
41444
+ ]);
41445
+ function analyzeCapabilitySurface(contract) {
41446
+ const resources = new Map((contract.resources ?? []).map((resource) => [resource.name, resource]));
41447
+ const indexed = contract.capabilities.map((capability) => {
41448
+ const resource = capability.subject.resource ? resources.get(capability.subject.resource) : void 0;
41449
+ const source = capability.source ?? resource?.engine ?? "unresolved-source";
41450
+ const schema = capability.subject.schema ?? resource?.schema ?? "unresolved-schema";
41451
+ const table = capability.subject.table ?? resource?.table ?? capability.subject.resource ?? "unresolved-object";
41452
+ return {
41453
+ capability,
41454
+ targetKey: `${source}\0${schema}\0${table}`,
41455
+ targetLabel: `${source}:${schema}.${table}`
41456
+ };
41457
+ });
41458
+ const findings = [];
41459
+ for (const entry of indexed) {
41460
+ collectGenericArgumentFindings(findings, entry);
41461
+ collectOperationNamingFinding(findings, entry);
41462
+ }
41463
+ const grouped = /* @__PURE__ */ new Map();
41464
+ for (const entry of indexed) grouped.set(entry.targetKey, [...grouped.get(entry.targetKey) ?? [], entry]);
41465
+ const targets = [...grouped.values()].map((entries) => {
41466
+ const sortedEntries = [...entries].sort((left, right) => left.capability.name.localeCompare(right.capability.name));
41467
+ const capabilityNames = sortedEntries.map((entry) => entry.capability.name);
41468
+ const densityWarning = entries.length > CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD;
41469
+ if (densityWarning) {
41470
+ findings.push({
41471
+ code: "SURFACE_TARGET_DENSITY",
41472
+ severity: "warning",
41473
+ path: "$.capabilities",
41474
+ message: `${entries[0].targetLabel} exposes ${entries.length} capabilities, above the advisory review threshold of ${CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD}. Review whether every operation belongs on the same model-facing surface or should be consolidated or assigned to a narrower agent.`,
41475
+ details: {
41476
+ target: entries[0].targetLabel,
41477
+ capability_count: entries.length,
41478
+ review_threshold: CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD,
41479
+ capabilities: capabilityNames
41480
+ }
41481
+ });
41482
+ }
41483
+ collectNearDuplicateFindings(findings, sortedEntries);
41484
+ return {
41485
+ target: entries[0].targetLabel,
41486
+ capability_count: entries.length,
41487
+ capabilities: capabilityNames,
41488
+ density_warning: densityWarning
41489
+ };
41490
+ }).sort((left, right) => left.target.localeCompare(right.target));
41491
+ findings.sort((left, right) => left.code.localeCompare(right.code) || left.path.localeCompare(right.path) || left.message.localeCompare(right.message));
41492
+ return {
41493
+ findings,
41494
+ summary: {
41495
+ total_capabilities: indexed.length,
41496
+ target_count: targets.length,
41497
+ density_review_threshold: CAPABILITY_SURFACE_DENSITY_REVIEW_THRESHOLD,
41498
+ targets
41499
+ }
41500
+ };
41501
+ }
41502
+ function collectGenericArgumentFindings(findings, entry) {
41503
+ const visit = (arg, argName, argPath) => {
41504
+ if (arg.type === "object_array") {
41505
+ for (const [fieldName, field] of Object.entries(arg.fields).sort(([left], [right]) => left.localeCompare(right))) {
41506
+ visit(field, fieldName, `${argPath}.fields.${fieldName}`);
41507
+ }
41508
+ return;
41509
+ }
41510
+ const normalizedName = normalizeIdentifier(argName);
41511
+ if (arg.type !== "string" || (arg.enum?.length ?? 0) > 0 || !GENERIC_ARGUMENT_NAMES.has(normalizedName)) return;
41512
+ findings.push({
41513
+ code: "SURFACE_GENERIC_ARGUMENT",
41514
+ severity: "warning",
41515
+ path: argPath,
41516
+ message: `${entry.capability.name} exposes un-enumerated string argument ${argName}, a generic query/predicate-style name. Runner does not turn it into SQL, but reviewers should confirm this remains a named business operation instead of an escape-hatch surface.`,
41517
+ details: { capability: entry.capability.name, argument: argName }
41518
+ });
41519
+ };
41520
+ for (const [argName, arg] of Object.entries(entry.capability.args).sort(([left], [right]) => left.localeCompare(right))) {
41521
+ visit(arg, argName, `${capabilityPath(entry.capability.name)}.args.${argName}`);
41522
+ }
41523
+ }
41524
+ function collectOperationNamingFinding(findings, entry) {
41525
+ const operation = entry.capability.name.split(".").at(-1) ?? entry.capability.name;
41526
+ const tokens = tokenizeIdentifier(operation);
41527
+ const genericOnly = tokens.length > 0 && tokens.every((token) => GENERIC_OPERATION_WORDS.has(token));
41528
+ const actionOriented = tokens.length >= 2 && (ACTION_PREFIXES.has(tokens[0]) || ACTION_SUFFIXES.has(tokens.at(-1)));
41529
+ if (tokens.length >= 2 && actionOriented && !genericOnly) return;
41530
+ findings.push({
41531
+ code: "SURFACE_OPERATION_NAMING",
41532
+ severity: "warning",
41533
+ path: `${capabilityPath(entry.capability.name)}.name`,
41534
+ message: `${entry.capability.name} does not read as a high-confidence named business operation. Use a reviewer-recognizable operation such as inspect_invoice or propose_plan_credit; this is a naming heuristic, not a runtime enforcement failure.`,
41535
+ details: { capability: entry.capability.name, operation, tokens }
41536
+ });
41537
+ }
41538
+ function collectNearDuplicateFindings(findings, entries) {
41539
+ for (let leftIndex = 0; leftIndex < entries.length; leftIndex += 1) {
41540
+ for (let rightIndex = leftIndex + 1; rightIndex < entries.length; rightIndex += 1) {
41541
+ const left = entries[leftIndex];
41542
+ const right = entries[rightIndex];
41543
+ if (left.capability.kind !== right.capability.kind || capabilityShape(left.capability) !== capabilityShape(right.capability)) continue;
41544
+ const relationship = compareArgumentSurfaces(left.capability.args, right.capability.args);
41545
+ if (!relationship) continue;
41546
+ const differences = relationship.differences.length > 0 ? relationship.differences : ["identical model-visible arguments"];
41547
+ findings.push({
41548
+ code: "SURFACE_NEAR_DUPLICATE",
41549
+ severity: "warning",
41550
+ path: "$.capabilities",
41551
+ message: `${left.capability.name} and ${right.capability.name} have the same target, kind, reviewed fields, targeting, and write/approval shape, with only this argument-surface difference: ${differences.join("; ")}. Review whether both operations are necessary or one is a loosened duplicate.`,
41552
+ details: {
41553
+ target: left.targetLabel,
41554
+ capabilities: [left.capability.name, right.capability.name],
41555
+ relationship: relationship.relationship,
41556
+ differences
41557
+ }
41558
+ });
41559
+ }
41560
+ }
41561
+ }
41562
+ function capabilityShape(capability) {
41563
+ const { name: _name, description: _description, returns_hint: _returnsHint, args: _args, ...shape } = capability;
41564
+ return stableString(shape);
41565
+ }
41566
+ function compareArgumentSurfaces(left, right) {
41567
+ if (stableString(stripArgumentDescriptions(left)) === stableString(stripArgumentDescriptions(right))) {
41568
+ return { relationship: "identical", differences: [] };
41569
+ }
41570
+ const leftLooser = argumentMapNoStricter(left, right);
41571
+ const rightLooser = argumentMapNoStricter(right, left);
41572
+ if (leftLooser && !rightLooser) return { relationship: "left_is_looser", differences: describeRelaxations(left, right) };
41573
+ if (rightLooser && !leftLooser) return { relationship: "right_is_looser", differences: describeRelaxations(right, left) };
41574
+ return void 0;
41575
+ }
41576
+ function argumentMapNoStricter(looser, stricter) {
41577
+ for (const [name, strictArg] of Object.entries(stricter)) {
41578
+ const looseArg = looser[name];
41579
+ if (!looseArg || !argumentNoStricter(looseArg, strictArg)) return false;
41580
+ }
41581
+ return Object.entries(looser).filter(([name]) => !(name in stricter)).every(([, arg]) => arg.required !== true);
41582
+ }
41583
+ function argumentNoStricter(looser, stricter) {
41584
+ if (looser.type !== stricter.type) return false;
41585
+ if (looser.required === true && stricter.required !== true) return false;
41586
+ if (looser.type === "object_array" && stricter.type === "object_array") {
41587
+ if (!sameExtensions(looser, stricter)) return false;
41588
+ if (looser.max_items < stricter.max_items) return false;
41589
+ return argumentMapNoStricter(looser.fields, stricter.fields);
41590
+ }
41591
+ if (looser.type === "object_array" || stricter.type === "object_array") return false;
41592
+ if (!sameExtensions(looser, stricter)) return false;
41593
+ if ((looser.max_length ?? Number.POSITIVE_INFINITY) < (stricter.max_length ?? Number.POSITIVE_INFINITY)) return false;
41594
+ if ((looser.minimum ?? Number.NEGATIVE_INFINITY) > (stricter.minimum ?? Number.NEGATIVE_INFINITY)) return false;
41595
+ if ((looser.maximum ?? Number.POSITIVE_INFINITY) < (stricter.maximum ?? Number.POSITIVE_INFINITY)) return false;
41596
+ return enumContains(looser.enum, stricter.enum);
41597
+ }
41598
+ function enumContains(looser, stricter) {
41599
+ if (!looser?.length) return true;
41600
+ if (!stricter?.length) return false;
41601
+ const looseValues = new Set(looser.map((value) => stableString(value)));
41602
+ return stricter.every((value) => looseValues.has(stableString(value)));
41603
+ }
41604
+ function describeRelaxations(looser, stricter) {
41605
+ const differences = [];
41606
+ for (const name of Object.keys(looser).sort()) {
41607
+ const looseArg = looser[name];
41608
+ const strictArg = stricter[name];
41609
+ if (!strictArg) {
41610
+ differences.push(`${name} adds an optional argument`);
41611
+ continue;
41612
+ }
41613
+ describeArgumentRelaxations(looseArg, strictArg, name, differences);
41614
+ }
41615
+ return differences;
41616
+ }
41617
+ function describeArgumentRelaxations(looser, stricter, path9, differences) {
41618
+ if (looser.required !== true && stricter.required === true) differences.push(`${path9} becomes optional`);
41619
+ if (looser.type === "object_array" && stricter.type === "object_array") {
41620
+ if (looser.max_items > stricter.max_items) differences.push(`${path9}.max_items increases from ${stricter.max_items} to ${looser.max_items}`);
41621
+ for (const name of Object.keys(looser.fields).sort()) {
41622
+ const looseField = looser.fields[name];
41623
+ const strictField = stricter.fields[name];
41624
+ if (!strictField) differences.push(`${path9}.${name} adds an optional field`);
41625
+ else describeArgumentRelaxations(looseField, strictField, `${path9}.${name}`, differences);
41626
+ }
41627
+ return;
41628
+ }
41629
+ if (looser.type === "object_array" || stricter.type === "object_array") return;
41630
+ if ((looser.max_length ?? Number.POSITIVE_INFINITY) > (stricter.max_length ?? Number.POSITIVE_INFINITY)) differences.push(`${path9}.max_length widens from ${displayBound(stricter.max_length)} to ${displayBound(looser.max_length)}`);
41631
+ if ((looser.minimum ?? Number.NEGATIVE_INFINITY) < (stricter.minimum ?? Number.NEGATIVE_INFINITY)) differences.push(`${path9}.minimum widens from ${displayBound(stricter.minimum)} to ${displayBound(looser.minimum)}`);
41632
+ if ((looser.maximum ?? Number.POSITIVE_INFINITY) > (stricter.maximum ?? Number.POSITIVE_INFINITY)) differences.push(`${path9}.maximum widens from ${displayBound(stricter.maximum)} to ${displayBound(looser.maximum)}`);
41633
+ if (stableString(looser.enum ?? []) !== stableString(stricter.enum ?? [])) differences.push(`${path9}.enum widens from ${displayEnum(stricter.enum)} to ${displayEnum(looser.enum)}`);
41634
+ }
41635
+ function stripArgumentDescriptions(args) {
41636
+ return Object.fromEntries(Object.entries(args).map(([name, arg]) => [name, stripArgumentDescription(arg)]));
41637
+ }
41638
+ function stripArgumentDescription(arg) {
41639
+ const { description: _description, ...rest } = arg;
41640
+ if (arg.type !== "object_array") return rest;
41641
+ return { ...rest, fields: Object.fromEntries(Object.entries(arg.fields).map(([name, field]) => [name, stripArgumentDescription(field)])) };
41642
+ }
41643
+ function sameExtensions(left, right) {
41644
+ const extensionEntries = (value) => Object.fromEntries(Object.entries(value).filter(([key]) => key.startsWith("x-")));
41645
+ return stableString(extensionEntries(left)) === stableString(extensionEntries(right));
41646
+ }
41647
+ function normalizeIdentifier(value) {
41648
+ return tokenizeIdentifier(value).join("_");
41649
+ }
41650
+ function capabilityPath(name) {
41651
+ return `$.capabilities[name=${JSON.stringify(name)}]`;
41652
+ }
41653
+ function tokenizeIdentifier(value) {
41654
+ return value.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
41655
+ }
41656
+ function stableString(value) {
41657
+ return JSON.stringify(stableValue(value));
41658
+ }
41659
+ function stableValue(value) {
41660
+ if (Array.isArray(value)) return value.map(stableValue).sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)));
41661
+ if (!value || typeof value !== "object") return value;
41662
+ return Object.fromEntries(Object.entries(value).filter(([, nested]) => nested !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, nested]) => [key, stableValue(nested)]));
41663
+ }
41664
+ function displayBound(value) {
41665
+ return value === void 0 ? "unbounded" : String(value);
41666
+ }
41667
+ function displayEnum(value) {
41668
+ return value?.length ? JSON.stringify([...value].map(stableValue).sort()) : "any value";
41669
+ }
41670
+
41671
+ // apps/runner/src/contract-tools.ts
41379
41672
  async function loadReviewedContract(sourcePath) {
41380
41673
  const absolute = path5.resolve(sourcePath);
41381
41674
  const sourceText = await fs5.readFile(absolute, "utf8");
@@ -41478,13 +41771,15 @@ function lintContract(contract, options = {}) {
41478
41771
  add({ code: "IRREVERSIBLE_DELETE_REVIEW", severity: "warning", path: `${base}.proposal.reversibility`, message: `${capability.name} performs a hard delete without reviewed inverse compensation.` });
41479
41772
  }
41480
41773
  }
41774
+ const surface = analyzeCapabilitySurface(contract);
41775
+ for (const finding of surface.findings) add(finding);
41481
41776
  issues.sort((left, right) => severityRank(left.severity) - severityRank(right.severity) || left.code.localeCompare(right.code) || left.path.localeCompare(right.path));
41482
41777
  const summary = {
41483
41778
  errors: issues.filter((issue) => issue.severity === "error").length,
41484
41779
  warnings: issues.filter((issue) => issue.severity === "warning").length,
41485
41780
  info: issues.filter((issue) => issue.severity === "info").length
41486
41781
  };
41487
- return { ok: summary.errors === 0, issues, summary };
41782
+ return { ok: summary.errors === 0, issues, summary, surface: surface.summary };
41488
41783
  }
41489
41784
  function formatContractLint(result, format) {
41490
41785
  if (format === "json") return `${JSON.stringify(result, null, 2)}
@@ -41499,13 +41794,16 @@ function formatContractLint(result, format) {
41499
41794
  ruleId: issue.code,
41500
41795
  level: issue.severity === "error" ? "error" : issue.severity === "warning" ? "warning" : "note",
41501
41796
  message: { text: issue.message },
41502
- locations: [{ physicalLocation: { artifactLocation: { uri: issue.path } } }]
41797
+ locations: [{ physicalLocation: { artifactLocation: { uri: issue.path } } }],
41798
+ ...issue.details ? { properties: issue.details } : {}
41503
41799
  }))
41504
41800
  }]
41505
41801
  }, null, 2)}
41506
41802
  `;
41507
41803
  }
41508
41804
  const lines = result.issues.map((issue) => `${issue.severity.toUpperCase()} ${issue.code} ${issue.path}: ${issue.message}`);
41805
+ const denseTargets = result.surface.targets.filter((target2) => target2.density_warning).length;
41806
+ lines.push(`Surface: ${result.surface.total_capabilities} model-facing capabilities across ${result.surface.target_count} targets; ${denseTargets} target(s) above the advisory density threshold of ${result.surface.density_review_threshold}`);
41509
41807
  lines.push(`Summary: ${result.summary.errors} error / ${result.summary.warnings} warning / ${result.summary.info} info`);
41510
41808
  return `${lines.join("\n")}
41511
41809
  `;
@@ -42753,7 +43051,7 @@ function rate(value) {
42753
43051
  // apps/runner/package.json
42754
43052
  var package_default = {
42755
43053
  name: "@synapsor/runner",
42756
- version: "1.4.122",
43054
+ version: "1.4.123",
42757
43055
  description: "Stop giving AI agents execute_sql; expose reviewed Postgres/MySQL MCP actions with proposals, approval, writeback, and replay.",
42758
43056
  license: "Apache-2.0",
42759
43057
  type: "module",
@@ -121,6 +121,22 @@ synapsor-runner mcp serve --config ./synapsor.runner.json --store ./.synapsor/lo
121
121
  capabilities cannot accidentally lose descriptions, returns hints, or numeric
122
122
  patch bounds during review.
123
123
 
124
+ After compilation, run the canonical contract through the surface review:
125
+
126
+ ```bash
127
+ synapsor-runner contract lint ./synapsor.contract.json
128
+ synapsor-runner contract lint ./synapsor.contract.json --strict
129
+ ```
130
+
131
+ The default command keeps capability-surface findings advisory; `--strict` opts
132
+ into warning-as-failure CI policy. Review capabilities using a simple fitness
133
+ test: if the name is not a business operation an audit log would recognize, ask
134
+ whether the model should receive it. Lint also surfaces generic query-like
135
+ string arguments, more than eight capabilities on one target, and structurally
136
+ near-duplicate tools. These checks make breadth drift visible; they do not
137
+ change canonical validity or replace human review. See [Contract
138
+ Review](contract-review.md) for exact codes and behavior.
139
+
124
140
  ## DSL / JSON Capability Parity
125
141
 
126
142
  The DSL compiles to canonical `@synapsor/spec` JSON. It must not silently weaken
@@ -33,9 +33,42 @@ cover objective review gaps such as missing descriptions/evidence, unbounded
33
33
  strings, unresolved wiring, irreversible operations, and policy/writeback
34
34
  contradictions. Lint does not claim to discover every sensitive column.
35
35
 
36
+ ### Review Capability-Surface Fitness
37
+
38
+ Runner structurally limits capability **depth**: contracts cannot turn a model
39
+ argument into raw SQL or a free-form predicate, and trusted tenant/principal
40
+ scope is never model input. A separate review concern is capability **breadth**:
41
+ many individually narrow tools can accumulate until the total model-facing
42
+ surface is difficult to understand.
43
+
44
+ Use this fitness test during review:
45
+
46
+ > If a capability cannot be described as a named business operation an audit
47
+ > log would recognize, it probably should not be exposed yet.
48
+
49
+ `contract lint` reports these deterministic advisory warnings:
50
+
51
+ | Code | Review signal |
52
+ | --- | --- |
53
+ | `SURFACE_GENERIC_ARGUMENT` | An un-enumerated string argument is literally named `filter`, `query`, `where`, `predicate`, or `sql`. Runner still does not interpolate it into SQL. |
54
+ | `SURFACE_TARGET_DENSITY` | More than eight capabilities target the same normalized source and object. Eight is a review threshold, not a runtime limit. |
55
+ | `SURFACE_OPERATION_NAMING` | A capability name does not match the conservative named-business-operation heuristic. |
56
+ | `SURFACE_NEAR_DUPLICATE` | Two capabilities have the same target, kind, reviewed fields, targeting, write/approval shape, and identical or directionally loosened arguments. |
57
+
58
+ The default command exits successfully when these advisories are the only
59
+ findings. `--strict` (or `--fail-on warning`) deliberately lets a team turn all
60
+ warnings into a CI policy gate without changing canonical contract validity.
61
+ Text, JSON, and SARIF use the same stable finding set and deterministic order.
62
+
63
+ Passing these checks does not prove that a surface is safe or well designed.
64
+ Review which operations each agent actually needs; use narrower contracts or
65
+ Runner/client deployments instead of exposing every organization capability to
66
+ every model.
67
+
36
68
  Expected successful output ends with:
37
69
 
38
70
  ```text
71
+ Surface: N model-facing capabilities across M targets; 0 target(s) above the advisory density threshold of 8
39
72
  Summary: 0 error / 0 warning / N info
40
73
  ```
41
74
 
@@ -10,7 +10,25 @@ 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
- ## 1.4.122 (prepared, not published)
13
+ ## 1.4.123 (prepared, not published)
14
+
15
+ ### Advisory capability-surface fitness lint
16
+
17
+ - `contract lint` now reports high-signal breadth-drift advisories for generic
18
+ query/predicate-style string arguments, capability density above eight on one
19
+ target, operation names that do not read as business actions, and structural
20
+ near-duplicates with identical or loosened arguments.
21
+ - Findings are deterministic across declaration order and share stable codes in
22
+ text, JSON, and SARIF. JSON/SARIF include reviewer-safe metrics and structural
23
+ differences without reading a database or environment values.
24
+ - Advisory-only lint still exits successfully by default. Teams may opt into a
25
+ CI policy gate with `--strict` or `--fail-on warning`; canonical validity and
26
+ runtime enforcement are unchanged.
27
+
28
+ Prepared package version: `@synapsor/runner@1.4.123`. Spec, DSL, and Cloud CLI
29
+ packages are unchanged.
30
+
31
+ ## 1.4.122 (2026-07-16)
14
32
 
15
33
  ### Trusted principal scope and Cloud-linked authority
16
34
 
@@ -142,7 +142,10 @@ credentials, approval tools, commit tools, or controls that widen reviewed
142
142
  tables/columns.
143
143
 
144
144
  Contract lint and tests are review aids rather than a proof of complete
145
- security. Scoped report exports omit evidence rows and kept-out values and are
145
+ security. Capability breadth can still drift as narrow tools accumulate;
146
+ surface-fitness lint makes high-signal generic, dense, poorly named, and
147
+ near-duplicate surfaces visible for review but does not enforce good design.
148
+ Scoped report exports omit evidence rows and kept-out values and are
146
149
  tamper-evident when their digest/signature verifies; local SQLite is not an
147
150
  immutable compliance service. Graduated-trust evaluation is operator-only,
148
151
  disabled by default, and can recommend/export a separate artifact but cannot
@@ -0,0 +1,190 @@
1
+ {
2
+ "spec_version": "0.1",
3
+ "kind": "SynapsorContract",
4
+ "metadata": {
5
+ "name": "capability surface fitness fixture",
6
+ "description": "Exercises advisory capability-breadth review without changing contract validity.",
7
+ "version": "1.0.0"
8
+ },
9
+ "resources": [
10
+ {
11
+ "name": "billing_invoices",
12
+ "engine": "postgres",
13
+ "schema": "public",
14
+ "table": "invoices",
15
+ "type": "table",
16
+ "primary_key": "id",
17
+ "tenant_key": "tenant_id",
18
+ "conflict_key": "updated_at"
19
+ }
20
+ ],
21
+ "contexts": [
22
+ {
23
+ "name": "billing_operator",
24
+ "bindings": [
25
+ {
26
+ "name": "tenant_id",
27
+ "source": "environment",
28
+ "key": "SYNAPSOR_TENANT_ID",
29
+ "required": true
30
+ }
31
+ ],
32
+ "tenant_binding": "tenant_id"
33
+ }
34
+ ],
35
+ "capabilities": [
36
+ {
37
+ "name": "billing.execute",
38
+ "description": "Look up one reviewed invoice reference.",
39
+ "returns_hint": "One allowlisted invoice row.",
40
+ "kind": "read",
41
+ "context": "billing_operator",
42
+ "source": "local_postgres",
43
+ "subject": { "resource": "billing_invoices" },
44
+ "args": {
45
+ "filter": { "type": "string", "required": true, "max_length": 128 }
46
+ },
47
+ "lookup": { "id_from_arg": "filter" },
48
+ "visible_fields": ["id", "tenant_id", "status"],
49
+ "kept_out_fields": ["card_token", "internal_note"],
50
+ "evidence": { "required": true, "query_audit": true },
51
+ "max_rows": 1
52
+ },
53
+ {
54
+ "name": "billing.inspect_invoice_primary",
55
+ "description": "Inspect one primary invoice.",
56
+ "returns_hint": "One allowlisted invoice row.",
57
+ "kind": "read",
58
+ "context": "billing_operator",
59
+ "source": "local_postgres",
60
+ "subject": { "resource": "billing_invoices" },
61
+ "args": {
62
+ "invoice_id": { "type": "string", "required": true, "max_length": 128 }
63
+ },
64
+ "lookup": { "id_from_arg": "invoice_id" },
65
+ "visible_fields": ["id", "tenant_id", "status"],
66
+ "kept_out_fields": ["card_token", "internal_note"],
67
+ "evidence": { "required": true, "query_audit": true },
68
+ "max_rows": 1
69
+ },
70
+ {
71
+ "name": "billing.inspect_invoice_copy",
72
+ "description": "Inspect the same reviewed invoice shape for duplicate detection.",
73
+ "returns_hint": "One allowlisted invoice row.",
74
+ "kind": "read",
75
+ "context": "billing_operator",
76
+ "source": "local_postgres",
77
+ "subject": { "resource": "billing_invoices" },
78
+ "args": {
79
+ "invoice_id": { "type": "string", "required": true, "max_length": 128 }
80
+ },
81
+ "lookup": { "id_from_arg": "invoice_id" },
82
+ "visible_fields": ["id", "tenant_id", "status"],
83
+ "kept_out_fields": ["card_token", "internal_note"],
84
+ "evidence": { "required": true, "query_audit": true },
85
+ "max_rows": 1
86
+ },
87
+ {
88
+ "name": "billing.inspect_invoice_secondary",
89
+ "description": "Inspect one secondary invoice.",
90
+ "returns_hint": "One allowlisted invoice row.",
91
+ "kind": "read",
92
+ "context": "billing_operator",
93
+ "source": "local_postgres",
94
+ "subject": { "resource": "billing_invoices" },
95
+ "args": {
96
+ "secondary_invoice_id": { "type": "string", "required": true, "max_length": 128 }
97
+ },
98
+ "lookup": { "id_from_arg": "secondary_invoice_id" },
99
+ "visible_fields": ["id", "tenant_id", "status"],
100
+ "kept_out_fields": ["card_token", "internal_note"],
101
+ "evidence": { "required": true, "query_audit": true },
102
+ "max_rows": 1
103
+ },
104
+ {
105
+ "name": "billing.inspect_invoice_archived",
106
+ "description": "Inspect one archived invoice.",
107
+ "returns_hint": "One allowlisted invoice row.",
108
+ "kind": "read",
109
+ "context": "billing_operator",
110
+ "source": "local_postgres",
111
+ "subject": { "resource": "billing_invoices" },
112
+ "args": {
113
+ "archived_invoice_id": { "type": "string", "required": true, "max_length": 128 }
114
+ },
115
+ "lookup": { "id_from_arg": "archived_invoice_id" },
116
+ "visible_fields": ["id", "tenant_id", "status"],
117
+ "kept_out_fields": ["card_token", "internal_note"],
118
+ "evidence": { "required": true, "query_audit": true },
119
+ "max_rows": 1
120
+ },
121
+ {
122
+ "name": "billing.inspect_invoice_disputed",
123
+ "description": "Inspect one disputed invoice.",
124
+ "returns_hint": "One allowlisted invoice row.",
125
+ "kind": "read",
126
+ "context": "billing_operator",
127
+ "source": "local_postgres",
128
+ "subject": { "resource": "billing_invoices" },
129
+ "args": {
130
+ "disputed_invoice_id": { "type": "string", "required": true, "max_length": 128 }
131
+ },
132
+ "lookup": { "id_from_arg": "disputed_invoice_id" },
133
+ "visible_fields": ["id", "tenant_id", "status"],
134
+ "kept_out_fields": ["card_token", "internal_note"],
135
+ "evidence": { "required": true, "query_audit": true },
136
+ "max_rows": 1
137
+ },
138
+ {
139
+ "name": "billing.inspect_invoice_overdue",
140
+ "description": "Inspect one overdue invoice.",
141
+ "returns_hint": "One allowlisted invoice row.",
142
+ "kind": "read",
143
+ "context": "billing_operator",
144
+ "source": "local_postgres",
145
+ "subject": { "resource": "billing_invoices" },
146
+ "args": {
147
+ "overdue_invoice_id": { "type": "string", "required": true, "max_length": 128 }
148
+ },
149
+ "lookup": { "id_from_arg": "overdue_invoice_id" },
150
+ "visible_fields": ["id", "tenant_id", "status"],
151
+ "kept_out_fields": ["card_token", "internal_note"],
152
+ "evidence": { "required": true, "query_audit": true },
153
+ "max_rows": 1
154
+ },
155
+ {
156
+ "name": "billing.inspect_invoice_paid",
157
+ "description": "Inspect one paid invoice.",
158
+ "returns_hint": "One allowlisted invoice row.",
159
+ "kind": "read",
160
+ "context": "billing_operator",
161
+ "source": "local_postgres",
162
+ "subject": { "resource": "billing_invoices" },
163
+ "args": {
164
+ "paid_invoice_id": { "type": "string", "required": true, "max_length": 128 }
165
+ },
166
+ "lookup": { "id_from_arg": "paid_invoice_id" },
167
+ "visible_fields": ["id", "tenant_id", "status"],
168
+ "kept_out_fields": ["card_token", "internal_note"],
169
+ "evidence": { "required": true, "query_audit": true },
170
+ "max_rows": 1
171
+ },
172
+ {
173
+ "name": "billing.inspect_invoice_voided",
174
+ "description": "Inspect one voided invoice.",
175
+ "returns_hint": "One allowlisted invoice row.",
176
+ "kind": "read",
177
+ "context": "billing_operator",
178
+ "source": "local_postgres",
179
+ "subject": { "resource": "billing_invoices" },
180
+ "args": {
181
+ "voided_invoice_id": { "type": "string", "required": true, "max_length": 128 }
182
+ },
183
+ "lookup": { "id_from_arg": "voided_invoice_id" },
184
+ "visible_fields": ["id", "tenant_id", "status"],
185
+ "kept_out_fields": ["card_token", "internal_note"],
186
+ "evidence": { "required": true, "query_audit": true },
187
+ "max_rows": 1
188
+ }
189
+ ]
190
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synapsor/runner",
3
- "version": "1.4.122",
3
+ "version": "1.4.123",
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",