@passioncode-ai/fabric-agent-adapter 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.26.0 - 2026-10-13
4
+
5
+ Feed events carry machine-readable `data` (contract DEC-0070, FAA-35), and a service says which protocol revisions it
6
+ accepts (DEC-0067, FAA-37). Both are in the pin `a046f57`, which does not move.
7
+
8
+ ### Added
9
+
10
+ - **`data` on an event.**
11
+ - `make_event` / `makeEvent` and `JsonlEventLog.append` take `data={schema, value}`.
12
+ - `schema` is the absolute URI of a payload schema the service publishes.
13
+ - `value` holds at most 32 members. Each is a scalar (a string of at most 500 characters), a list of at most 32
14
+ scalars, or an object of at most 32 scalar members.
15
+ - FAC-SEM-101 refuses a string under a credential's name or of a credential's shape, at either level. The refusal names
16
+ the place, never the value.
17
+ - `feed_data_findings` / `feedDataFindings` and `feed_data_problems` / `feedDataProblems`.
18
+ - **Protocol revisions.**
19
+ - `PROTOCOL_REVISIONS` (the contract's register), `parse_revision`, `accepted_revisions` (N and N−1),
20
+ `negotiate_revision` / `negotiateRevision` (`schema-unsupported`, or the receiver's own `no-accepted-revisions`) and
21
+ `protocols_findings` / `protocolsFindings` (FAC-SEM-099).
22
+ - `build_well_known` / `buildWellKnown` take `protocols`. List them only once your hosts are pinned at or after
23
+ DEC-0067, because the document is closed for an older reader.
24
+ - **`check_service.py`** reads `well-known.protocols` and `events.data`.
25
+
26
+ ### Evidence
27
+
28
+ Every case below is answered by the contract's own functions at `a046f57` (`vendor_vectors.mjs`):
29
+ - the 7 `negotiateRevision` cases and the register;
30
+ - FAC-SEM-099 over 14 lists;
31
+ - every events-page and well-known-protocols fixture as the catalogue lists it;
32
+ - FAC-SEM-101 over 10 pages.
33
+
34
+ N and N−1 cannot occur at this pin, because each protocol has one revision. That rule is held on a register with three
35
+ revisions. 20 planted defects were all caught. The scenario eval `feed-data-and-protocols` was written before the prose.
36
+
3
37
  ## 0.25.0 - 2026-10-13
4
38
 
5
39
  Identifier equality by normal form (contract DEC-0076) in both kits, and the contract pin moves from `fe55fc6` to
@@ -711,7 +745,7 @@ DEC-0020 and DEC-0019 revisions stay valid.
711
745
  code and reads the declared `minWidth`. Without Playwright every rule is `NOT_RUN`. It measures a settled
712
746
  page: after the load it waits for the page's own requests to finish, and at each width for two frames,
713
747
  250 ms and two more frames, so a header that grows when its data arrives is not measured between its new
714
- size and the kit's reaction to it (seen once on the brand board at 360 px: a false ED-5).
748
+ size and the kit's reaction to it (seen once on an adopting service's dashboard at 360 px: a false ED-5).
715
749
  - **`check_service.py` rule `dashboard.declared`** (ED-9): PASS for a valid declaration (and names a newer
716
750
  kit), FAIL for an invalid one, NOT_RUN for no dashboard or a legacy one.
717
751
  - **`dashboard_extension()` / `dashboardExtension()`** in both kits, and `extensions` in `build_well_known` /
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
5
5
  "scripts": {
6
6
  "test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
@@ -3,7 +3,7 @@
3
3
  "name": "fabric-agent-adapter",
4
4
  "displayName": "Fabric Agent Adapter",
5
5
  "description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.25.0",
6
+ "version": "0.26.0",
7
7
  "author": {
8
8
  "name": "PassionCode.ai",
9
9
  "url": "https://passioncode.ai/"
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.25.0"
8
+ version: "0.26.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
@@ -16,7 +16,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
16
16
  compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
17
17
  metadata:
18
18
  author: PassionCode.ai
19
- version: "0.25.0"
19
+ version: "0.26.0"
20
20
  contract-version: "0.1.0"
21
21
  extension: "fabric-service/0.1"
22
22
  extension-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
@@ -116,6 +116,24 @@ empty, or `null` for an empty log. Retain at least seven days or 1000 events.
116
116
  path on the service origin, never an absolute URL. Optional SSE stream at
117
117
  `surfaces.events.stream` carrying the same objects.
118
118
 
119
+ **Machine-readable `data` (DEC-0070).** An event may carry `data {schema, value}` beside its sentence: `schema` is the
120
+ absolute URI of a payload schema the service publishes; `value` is an object of at most 32 members, each a scalar (a
121
+ string of at most 500 characters, a number, a boolean, null), a list of at most 32 scalars, or an object of at most 32
122
+ scalar members. `text` stays the event: a host that ignores `data` loses nothing, and one that does not know the schema
123
+ treats `value` as opaque. No string in `value` sits under a credential's name or has a credential's shape
124
+ (`FAC-SEM-101`); personal data is the service's own duty. `make_event` / `makeEvent` (and `JsonlEventLog.append`) take
125
+ `data=` and refuse one that breaks either rule, naming the place, never the value; `check_service.py` checks the feed it
126
+ fetches (`events.data`). A host pinned before DEC-0070 that validates events against a closed copy refuses the member.
127
+
128
+ **Protocol revisions (DEC-0067).** The well-known document may list `protocols`, the revisions the service accepts: at
129
+ most N and N−1 per protocol, consecutive in the contract's register, each `<name>/<revision>` once, 1 to 64 entries
130
+ (`FAC-SEM-099`). `build_well_known` / `buildWellKnown` take `protocols=` and check it; list them only once your hosts
131
+ are pinned at or after DEC-0067, because the document is closed for an older reader. A service that receives a document
132
+ in a revision it does not read answers `negotiate_revision` / `negotiateRevision`'s `schema-unsupported` refusal before
133
+ reading it further (HTTP 400, or an MCP `isError` result's `structuredContent`); a receiver that accepts none answers its
134
+ own fault `no-accepted-revisions`. `PROTOCOL_REVISIONS` is the contract's register; `check_service.py` reads
135
+ `well-known.protocols`.
136
+
119
137
  ## Login code and the operator session — `POST /fabric/v1/login-code`
120
138
 
121
139
  Token required → `{ "url": "/fabric/v1/login?code=<16–256 url-safe chars>", "expiresAt": "…" }`,
@@ -260,6 +260,11 @@ class Probe:
260
260
  if "events" not in (wk.get("surfaces") or {}):
261
261
  problems.append("surfaces.events missing")
262
262
  self.add("well-known.shape", "FAIL" if problems else "PASS", "; ".join(problems) or "protocol, build, status, degraded, surfaces")
263
+ if "protocols" in wk:
264
+ # DEC-0067, FAC-SEM-099: the revisions it accepts, N and N−1 per protocol, each once.
265
+ findings = fs.protocols_findings(wk["protocols"])
266
+ self.add("well-known.protocols", "FAIL" if findings else "PASS",
267
+ "; ".join("%s %s" % (f["instancePath"], f["message"]) for f in findings) or ", ".join(wk["protocols"]))
263
268
  self.configuration_rule()
264
269
  svc = wk.get("service") or {}
265
270
  answered = "%s.%s" % (svc.get("id"), svc.get("instance"))
@@ -346,6 +351,11 @@ class Probe:
346
351
  bad.append("%s (text is not a sentence)" % e.get("id"))
347
352
  self.add("events.page", "FAIL" if bad else "PASS",
348
353
  ("bad events: " + ", ".join(bad)) if bad else "%d event(s), cursor %r" % (len(events), page["cursor"]))
354
+ # DEC-0070, FAC-SEM-101: an event's data never carries a credential; the finding names the place, never the value.
355
+ leaks = fs.feed_data_findings(page)
356
+ if any(isinstance(e, dict) and "data" in e for e in events) or leaks:
357
+ self.add("events.data", "FAIL" if leaks else "PASS",
358
+ "; ".join("%s: %s" % (f["instancePath"], f["message"]) for f in leaks) or "every event's data is free of credentials")
349
359
 
350
360
  def usage_rules(self) -> None:
351
361
  """DEC-0021: a declared usage report requires the token and adds up (FAC-SEM-025)."""
@@ -9,7 +9,7 @@ import fs from 'node:fs';
9
9
  import net from 'node:net';
10
10
  import os from 'node:os';
11
11
  import path from 'node:path';
12
- import { credentialName, secretArgument, secretValue } from './fabric-secrets.mjs';
12
+ import { credentialName, secretArgument, secretUrl, secretValue } from './fabric-secrets.mjs';
13
13
 
14
14
  export const PROTOCOL = 'fabric-service/0.1';
15
15
  export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/service/0.1';
@@ -818,7 +818,7 @@ export function configurationProblems(doc) {
818
818
  return problems;
819
819
  }
820
820
 
821
- export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration }) {
821
+ export function buildWellKnown({ id, instance, name, version, build, startedAt, status, degraded = [], surfaces, summary, updateAvailable = null, pid = process.pid, extensions, configuration, protocols }) {
822
822
  if (!STATUSES.includes(status)) throw new ServiceError(`status must be one of ${STATUSES.join(', ')}.`);
823
823
  if (!surfaces?.events) throw new ServiceError('surfaces.events is required.');
824
824
  if (!(build?.commit || build?.digest)) throw new ServiceError('build needs a commit or a digest.');
@@ -844,12 +844,132 @@ export function buildWellKnown({ id, instance, name, version, build, startedAt,
844
844
  if (problems.length) throw new ServiceError(`configuration: ${problems.join('; ')}.`);
845
845
  doc.configuration = configuration;
846
846
  }
847
+ if (protocols !== undefined) {
848
+ // DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so a
849
+ // service lists them only once its hosts are pinned at or after it.
850
+ const problems = protocolsFindings(protocols);
851
+ if (problems.length) throw new ServiceError(`protocols: ${problems.map((f) => f.message).join('; ')}.`);
852
+ doc.protocols = [...protocols];
853
+ }
847
854
  return doc;
848
855
  }
849
856
 
850
857
  // An event about traced work carries traceId and spanId together (fabric-interop/0.1 C3.4 c);
851
858
  // traceIds(traceparent) in fabric-interop.mjs gives both.
852
- export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId } = {}) {
859
+ // --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data ---------------------------------------
860
+
861
+ /** Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`); a test holds it equal to the contract's. */
862
+ export const PROTOCOL_REVISIONS = new Map(Object.entries({
863
+ 'access-log': ['1'], approval: ['1'], 'activity-summary': ['1'], 'estate-record': ['1'], 'execution-packet': ['1'],
864
+ 'fabric-activity': ['0.1'], 'fabric-dashboard': ['0.1'], 'fabric-delegation': ['0.1'], 'fabric-device': ['0.1'],
865
+ 'fabric-interop': ['0.1'], 'fabric-local-channel': ['0.1'], 'fabric-local-runner': ['0.1'], 'fabric-operator-channel': ['0.1'],
866
+ 'fabric-package': ['1'], 'fabric-project-comms': ['0.1'], 'fabric-proposal': ['1'], 'fabric-provider': ['0.1'], 'fabric-relay': ['0.1'],
867
+ 'fabric-secret-envelope': ['1'], 'fabric-service': ['0.1'], 'fabric-settings-backup': ['1'], heartbeat: ['1'], 'learning-note': ['1'],
868
+ memory: ['0.1'], pipeline: ['0.1'], 'revocation-delta': ['1'], 'session-handoff': ['1'], 'window-lease': ['1'],
869
+ }));
870
+ export const MAX_PROTOCOLS = 64;
871
+ const REVISION = /^([a-z][a-z0-9.-]{0,63})\/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})$/;
872
+
873
+ /** `<name>/<revision>` split, or undefined. */
874
+ export function parseRevision(value) {
875
+ const m = typeof value === 'string' ? REVISION.exec(value) : null;
876
+ return m ? { name: m[1], revision: m[2] } : undefined;
877
+ }
878
+
879
+ /** The revisions a receiver implementing `name` up to `newest` accepts: N and, when the register has one, N−1. */
880
+ export function acceptedRevisions(name, newest) {
881
+ const known = PROTOCOL_REVISIONS.get(name);
882
+ if (!known?.length) return [];
883
+ const top = newest === undefined ? known.length - 1 : known.indexOf(newest);
884
+ if (top < 0) return [];
885
+ return known.slice(Math.max(0, top - 1), top + 1).reverse().map((r) => `${name}/${r}`);
886
+ }
887
+
888
+ /**
889
+ * A receiver's answer to a document naming `received` (DEC-0067, the contract's `negotiateRevision`): `{ok: true}`, the
890
+ * `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted revisions of that
891
+ * protocol (all of them when it names none the receiver reads), or `{fault: 'no-accepted-revisions'}`.
892
+ */
893
+ export function negotiateRevision(received, accepted) {
894
+ const usable = [...new Set((Array.isArray(accepted) ? accepted : []).filter((e) => parseRevision(e) !== undefined))].slice(0, MAX_PROTOCOLS);
895
+ if (!usable.length) return { fault: 'no-accepted-revisions' };
896
+ if (typeof received === 'string' && usable.includes(received)) return { ok: true };
897
+ const parsed = parseRevision(received);
898
+ const same = parsed ? usable.filter((e) => parseRevision(e)?.name === parsed.name) : [];
899
+ return { error: { code: 'schema-unsupported', message: 'this receiver does not read that protocol revision',
900
+ data: { protocol: typeof received === 'string' ? Array.from(received).slice(0, 80).join('') : '', accepted: same.length ? same : usable } } };
901
+ }
902
+
903
+ /** FAC-SEM-099 (DEC-0067): 1 to 64 entries, each once; at most N and N−1 of one protocol, consecutive in the register. */
904
+ export function protocolsFindings(protocols, prefix = '/protocols') {
905
+ const flag = (instancePath, message) => ({ code: 'FAC-SEM-099', instancePath, message });
906
+ if (!Array.isArray(protocols)) return [flag(prefix, 'protocols is a list of protocol revisions')];
907
+ if (protocols.length === 0 || protocols.length > MAX_PROTOCOLS) return [flag(prefix, `protocols lists 1 to ${MAX_PROTOCOLS} revisions`)];
908
+ const findings = [];
909
+ const byName = new Map();
910
+ const seen = new Set();
911
+ protocols.forEach((entry, i) => {
912
+ const at = `${prefix}/${i}`;
913
+ const parsed = parseRevision(entry);
914
+ if (!parsed) { findings.push(flag(at, 'a protocol revision is <name>/<revision>, such as fabric-service/0.1')); return; }
915
+ if (seen.has(entry)) { findings.push(flag(at, 'each protocol revision once')); return; }
916
+ seen.add(entry);
917
+ const known = PROTOCOL_REVISIONS.get(parsed.name);
918
+ if (known && !known.includes(parsed.revision)) { findings.push(flag(at, `${parsed.name} has no published revision ${parsed.revision}`)); return; }
919
+ const indices = byName.get(parsed.name) ?? [];
920
+ indices.push(known ? known.indexOf(parsed.revision) : -1);
921
+ byName.set(parsed.name, indices);
922
+ if (indices.length > 2) findings.push(flag(at, 'at most 2 revisions of one protocol: N and N−1'));
923
+ else if (known && indices.length === 2 && Math.abs(indices[0] - indices[1]) !== 1) findings.push(flag(at, 'two revisions of one protocol are N and N−1, consecutive in the register'));
924
+ });
925
+ return findings;
926
+ }
927
+
928
+ const SCHEMA_URI = /^[A-Za-z][A-Za-z0-9+.-]*:\S+$/;
929
+ const scalar = (v) => v === null || typeof v === 'boolean' || (typeof v === 'number' && Number.isFinite(v)) || (typeof v === 'string' && Array.from(v).length <= 500);
930
+ const isPlain = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
931
+ const pointerToken = (name) => String(name).replace(/~/g, '~0').replace(/\//g, '~1');
932
+
933
+ /** FAC-SEM-101 over an events page (the contract's `feedDataFindings`): no credential's name or shape in an event's data. */
934
+ export function feedDataFindings(page) {
935
+ const findings = [];
936
+ const check = (named, member, at) => {
937
+ if (typeof member !== 'string') return;
938
+ if (named) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data puts no string under a credential's name" });
939
+ else if (secretValue(member) || secretUrl(member)) findings.push({ code: 'FAC-SEM-101', instancePath: at, message: "an event's data carries no value shaped like a credential" });
940
+ };
941
+ (Array.isArray(page?.events) ? page.events : []).forEach((event, i) => {
942
+ if (!isPlain(event) || !isPlain(event.data) || !isPlain(event.data.value)) return;
943
+ for (const [key, member] of Object.entries(event.data.value)) {
944
+ const at = `/events/${i}/data/value/${pointerToken(key)}`;
945
+ const named = credentialName(key);
946
+ check(named, member, at);
947
+ if (Array.isArray(member)) member.forEach((item, j) => check(named, item, `${at}/${j}`));
948
+ else if (isPlain(member)) for (const [inner, leaf] of Object.entries(member)) check(named || credentialName(inner), leaf, `${at}/${pointerToken(inner)}`);
949
+ }
950
+ });
951
+ return findings;
952
+ }
953
+
954
+ /** DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101. */
955
+ export function feedDataProblems(data) {
956
+ if (!isPlain(data) || Object.keys(data).length !== 2 || !('schema' in data) || !('value' in data)) return ['data is {schema, value}'];
957
+ const problems = [];
958
+ if (!(typeof data.schema === 'string' && SCHEMA_URI.test(data.schema))) problems.push('data.schema is the absolute URI of a payload schema the service publishes');
959
+ const value = data.value;
960
+ if (!isPlain(value) || Object.keys(value).length > 32) return [...problems, 'data.value is an object of at most 32 members'];
961
+ for (const [key, member] of Object.entries(value)) {
962
+ if (scalar(member)) continue;
963
+ if (Array.isArray(member) && member.length <= 32 && member.every(scalar)) continue;
964
+ if (isPlain(member) && Object.keys(member).length <= 32 && Object.values(member).every(scalar)) continue;
965
+ problems.push(`data.value.${key} is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members`);
966
+ }
967
+ if (problems.length) return problems;
968
+ return feedDataFindings({ events: [{ data }] }).map((f) => `FAC-SEM-101: ${f.instancePath.replace('/events/0', '')} ${f.message}`);
969
+ }
970
+
971
+ /** One activity event. `data` (DEC-0070) adds machine-readable fields, `{schema, value}`, never a secret: refused here, so no feed carries one. */
972
+ export function makeEvent(id, at, kind, level, text, { subject, link, notify, traceId, spanId, data } = {}) {
853
973
  if (!LEVELS.includes(level)) throw new ServiceError(`level must be one of ${LEVELS.join(', ')}.`);
854
974
  if (!KIND.test(kind)) throw new ServiceError(`kind ${kind} must be dotted lowercase.`);
855
975
  const sentence = String(text).split(/\s+/).filter(Boolean).join(' ');
@@ -862,6 +982,11 @@ export function makeEvent(id, at, kind, level, text, { subject, link, notify, tr
862
982
  if (link) event.link = link;
863
983
  if (notify) event.notify = true;
864
984
  if (traceId !== undefined) Object.assign(event, { traceId, spanId });
985
+ if (data !== undefined) {
986
+ const problems = feedDataProblems(data);
987
+ if (problems.length) throw new ServiceError(`Event data: ${problems.join('; ')}.`);
988
+ event.data = { schema: data.schema, value: data.value };
989
+ }
865
990
  return event;
866
991
  }
867
992
 
@@ -1305,7 +1305,8 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
1305
1305
  surfaces: Dict[str, Any], summary: Optional[Sequence[Dict[str, Any]]] = None,
1306
1306
  update_available: Optional[str] = None, pid: Optional[int] = None,
1307
1307
  extensions: Optional[Dict[str, Any]] = None,
1308
- configuration: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1308
+ configuration: Optional[Dict[str, Any]] = None,
1309
+ protocols: Optional[Sequence[str]] = None) -> Dict[str, Any]:
1309
1310
  """The unauthenticated identity answer. `degraded` is always present; ready means empty.
1310
1311
  `configuration` (DEC-0046) says what the operator can configure and what applies now; fabric_profiles builds it.
1311
1312
  `extensions` is kept as given (a reader ignores keys it does not know); a dashboard declaration
@@ -1339,15 +1340,182 @@ def build_well_known(*, service_id: str, instance: str, name: str, version: str,
1339
1340
  if problems:
1340
1341
  raise ServiceError("configuration: %s." % "; ".join(problems))
1341
1342
  doc["configuration"] = configuration
1343
+ if protocols is not None:
1344
+ # DEC-0067: the revisions this service accepts. The document is closed for a reader pinned before DEC-0067, so
1345
+ # a service lists them only once its hosts are pinned at or after it.
1346
+ problems = protocols_findings(list(protocols))
1347
+ if problems:
1348
+ raise ServiceError("protocols: %s." % "; ".join(f["message"] for f in problems))
1349
+ doc["protocols"] = list(protocols)
1342
1350
  return doc
1343
1351
 
1344
1352
 
1353
+ # --- DEC-0067: protocol revisions; DEC-0070: an event's machine-readable data --------------------------------------
1354
+
1355
+ #: Each contract protocol's published revisions, oldest first (the contract's `PROTOCOL_REVISIONS`, DEC-0067); a test
1356
+ #: holds it equal to the contract's register at the pin.
1357
+ PROTOCOL_REVISIONS: Dict[str, Tuple[str, ...]] = {
1358
+ "access-log": ("1",), "approval": ("1",), "activity-summary": ("1",), "estate-record": ("1",), "execution-packet": ("1",),
1359
+ "fabric-activity": ("0.1",), "fabric-dashboard": ("0.1",), "fabric-delegation": ("0.1",), "fabric-device": ("0.1",),
1360
+ "fabric-interop": ("0.1",), "fabric-local-channel": ("0.1",), "fabric-local-runner": ("0.1",),
1361
+ "fabric-operator-channel": ("0.1",), "fabric-package": ("1",), "fabric-project-comms": ("0.1",), "fabric-proposal": ("1",),
1362
+ "fabric-provider": ("0.1",), "fabric-relay": ("0.1",), "fabric-secret-envelope": ("1",), "fabric-service": ("0.1",),
1363
+ "fabric-settings-backup": ("1",), "heartbeat": ("1",), "learning-note": ("1",), "memory": ("0.1",), "pipeline": ("0.1",),
1364
+ "revocation-delta": ("1",), "session-handoff": ("1",), "window-lease": ("1",),
1365
+ }
1366
+ MAX_PROTOCOLS = 64
1367
+ _REVISION = re.compile(r"([a-z][a-z0-9.-]{0,63})/([0-9]{1,4}(?:\.[0-9]{1,4}){0,2})", re.ASCII)
1368
+
1369
+
1370
+ def parse_revision(value: Any) -> Optional[Tuple[str, str]]:
1371
+ """`<name>/<revision>` split, or None."""
1372
+ match = _REVISION.fullmatch(value) if isinstance(value, str) else None
1373
+ return (match.group(1), match.group(2)) if match else None
1374
+
1375
+
1376
+ def accepted_revisions(name: str, newest: Optional[str] = None) -> List[str]:
1377
+ """The revisions a receiver implementing ``name`` up to ``newest`` accepts: N and, when the register has one, N−1."""
1378
+ known = PROTOCOL_REVISIONS.get(name, ())
1379
+ if not known:
1380
+ return []
1381
+ top = len(known) - 1 if newest is None else (known.index(newest) if newest in known else -1)
1382
+ if top < 0:
1383
+ return []
1384
+ return ["%s/%s" % (name, r) for r in reversed(known[max(0, top - 1):top + 1])]
1385
+
1386
+
1387
+ def negotiate_revision(received: Any, accepted: Sequence[Any]) -> Dict[str, Any]:
1388
+ """A receiver's answer to a document naming ``received`` (DEC-0067, the contract's `negotiateRevision`):
1389
+ ``{"ok": True}``; the `schema-unsupported` refusal naming what was received (at most 80 characters) and the accepted
1390
+ revisions of that protocol (all of them when it names none the receiver reads); or ``{"fault":
1391
+ "no-accepted-revisions"}``, the receiver's own, when it accepts no well-formed revision. Revisions compare exactly."""
1392
+ usable: List[str] = []
1393
+ for entry in accepted if isinstance(accepted, (list, tuple)) else []:
1394
+ if parse_revision(entry) and entry not in usable:
1395
+ usable.append(entry)
1396
+ usable = usable[:MAX_PROTOCOLS]
1397
+ if not usable:
1398
+ return {"fault": "no-accepted-revisions"}
1399
+ if isinstance(received, str) and received in usable:
1400
+ return {"ok": True}
1401
+ parsed = parse_revision(received)
1402
+ same = [e for e in usable if parsed and parse_revision(e)[0] == parsed[0]]
1403
+ return {"error": {"code": "schema-unsupported", "message": "this receiver does not read that protocol revision",
1404
+ "data": {"protocol": received[:80] if isinstance(received, str) else "", "accepted": same or usable}}}
1405
+
1406
+
1407
+ def protocols_findings(protocols: Any, prefix: str = "/protocols") -> List[Dict[str, str]]:
1408
+ """FAC-SEM-099 (DEC-0067): 1 to 64 entries, each `<name>/<revision>` once; at most two revisions of one protocol (an
1409
+ unpublished one refused and not counted), and two of a contract protocol are N and N−1, consecutive in the register."""
1410
+ flag = lambda at, message: {"code": "FAC-SEM-099", "instancePath": at, "message": message} # noqa: E731
1411
+ if not isinstance(protocols, list):
1412
+ return [flag(prefix, "protocols is a list of protocol revisions")]
1413
+ if not 1 <= len(protocols) <= MAX_PROTOCOLS:
1414
+ return [flag(prefix, "protocols lists 1 to %d revisions" % MAX_PROTOCOLS)]
1415
+ findings: List[Dict[str, str]] = []
1416
+ by_name: Dict[str, List[int]] = {}
1417
+ seen: List[str] = []
1418
+ for i, entry in enumerate(protocols):
1419
+ at = "%s/%d" % (prefix, i)
1420
+ parsed = parse_revision(entry)
1421
+ if not parsed:
1422
+ findings.append(flag(at, "a protocol revision is <name>/<revision>, such as fabric-service/0.1"))
1423
+ continue
1424
+ if entry in seen:
1425
+ findings.append(flag(at, "each protocol revision once"))
1426
+ continue
1427
+ seen.append(entry)
1428
+ known = PROTOCOL_REVISIONS.get(parsed[0])
1429
+ if known and parsed[1] not in known:
1430
+ findings.append(flag(at, "%s has no published revision %s" % parsed))
1431
+ continue
1432
+ indices = by_name.setdefault(parsed[0], [])
1433
+ indices.append(known.index(parsed[1]) if known else -1)
1434
+ if len(indices) > 2:
1435
+ findings.append(flag(at, "at most 2 revisions of one protocol: N and N−1"))
1436
+ elif known and len(indices) == 2 and abs(indices[0] - indices[1]) != 1:
1437
+ findings.append(flag(at, "two revisions of one protocol are N and N−1, consecutive in the register"))
1438
+ return findings
1439
+
1440
+
1441
+ _SCHEMA_URI = re.compile(r"[A-Za-z][A-Za-z0-9+.-]*:[^\s]+", re.ASCII)
1442
+
1443
+
1444
+ def _scalar(value: Any) -> bool:
1445
+ return value is None or isinstance(value, (bool, int, float)) or (isinstance(value, str) and len(value) <= 500)
1446
+
1447
+
1448
+ def feed_data_problems(data: Any) -> List[str]:
1449
+ """DEC-0070's `data {schema, value}` as the schema bounds it, then FAC-SEM-101: no string in `value`, at either
1450
+ level, under a credential's name or of a credential's shape (named by its place, never its value)."""
1451
+ import fabric_secrets # noqa: E402 - beside this file; the contract's detector
1452
+ if not isinstance(data, dict) or set(data) != {"schema", "value"}:
1453
+ return ["data is {schema, value}"]
1454
+ problems: List[str] = []
1455
+ if not (isinstance(data["schema"], str) and _SCHEMA_URI.fullmatch(data["schema"])):
1456
+ problems.append("data.schema is the absolute URI of a payload schema the service publishes")
1457
+ value = data["value"]
1458
+ if not isinstance(value, dict) or len(value) > 32:
1459
+ return problems + ["data.value is an object of at most 32 members"]
1460
+ for key, member in value.items():
1461
+ if _scalar(member):
1462
+ continue
1463
+ if isinstance(member, list) and len(member) <= 32 and all(_scalar(m) for m in member):
1464
+ continue
1465
+ if isinstance(member, dict) and len(member) <= 32 and all(_scalar(m) for m in member.values()):
1466
+ continue
1467
+ problems.append("data.value.%s is a scalar (a string of at most 500 characters), a list of at most 32 scalars or an object of at most 32 scalar members" % key)
1468
+ if problems:
1469
+ return problems
1470
+ for finding in feed_data_findings({"events": [{"data": data}]}):
1471
+ problems.append("FAC-SEM-101: %s %s" % (finding["instancePath"].replace("/events/0", ""), finding["message"]))
1472
+ return problems
1473
+
1474
+
1475
+ def _pointer_token(name: str) -> str:
1476
+ return str(name).replace("~", "~0").replace("/", "~1")
1477
+
1478
+
1479
+ def feed_data_findings(page: Any) -> List[Dict[str, str]]:
1480
+ """FAC-SEM-101 over an events page (the contract's `feedDataFindings`): a list's items and an object's members sit
1481
+ under the outer name too; a number is a count, never a credential."""
1482
+ import fabric_secrets # noqa: E402
1483
+ findings: List[Dict[str, str]] = []
1484
+
1485
+ def check(named: bool, member: Any, at: str) -> None:
1486
+ if not isinstance(member, str):
1487
+ return
1488
+ if named:
1489
+ findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data puts no string under a credential's name"})
1490
+ elif fabric_secrets.secret_value(member) or fabric_secrets.secret_url(member):
1491
+ findings.append({"code": "FAC-SEM-101", "instancePath": at, "message": "an event's data carries no value shaped like a credential"})
1492
+
1493
+ events = page.get("events") if isinstance(page, dict) else None
1494
+ for i, event in enumerate(events if isinstance(events, list) else []):
1495
+ data = event.get("data") if isinstance(event, dict) else None
1496
+ value = data.get("value") if isinstance(data, dict) else None
1497
+ if not isinstance(value, dict):
1498
+ continue
1499
+ for key, member in value.items():
1500
+ at = "/events/%d/data/value/%s" % (i, _pointer_token(key))
1501
+ named = fabric_secrets.credential_name(key)
1502
+ check(named, member, at)
1503
+ if isinstance(member, list):
1504
+ for j, item in enumerate(member):
1505
+ check(named, item, "%s/%d" % (at, j))
1506
+ elif isinstance(member, dict):
1507
+ for inner, leaf in member.items():
1508
+ check(named or fabric_secrets.credential_name(inner), leaf, "%s/%s" % (at, _pointer_token(inner)))
1509
+ return findings
1510
+
1511
+
1345
1512
  # --- events ------------------------------------------------------------------
1346
1513
 
1347
1514
  def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subject: Optional[Dict[str, str]] = None,
1348
1515
  link: Optional[str] = None, notify: bool = False, trace_id: Optional[str] = None,
1349
- span_id: Optional[str] = None) -> Dict[str, Any]:
1350
- """One activity event: one sentence a person reads, never a machine id.
1516
+ span_id: Optional[str] = None, data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
1517
+ """One activity event: one sentence a person reads, never a machine id. `data` (DEC-0070) adds machine-readable
1518
+ fields beside the sentence, `{schema, value}`, never a secret: it is refused here, so no feed carries one.
1351
1519
 
1352
1520
  An event about traced work carries its trace as a pair, `trace_id` and `span_id`
1353
1521
  (fabric-interop/0.1 C3.4 c); fabric_interop.trace_ids(traceparent) gives both."""
@@ -1373,6 +1541,11 @@ def make_event(event_id: Any, at: str, kind: str, level: str, text: str, *, subj
1373
1541
  event["notify"] = True
1374
1542
  if trace_id is not None:
1375
1543
  event["traceId"], event["spanId"] = trace_id, span_id
1544
+ if data is not None:
1545
+ problems = feed_data_problems(data)
1546
+ if problems:
1547
+ raise ServiceError("Event data: %s." % "; ".join(problems))
1548
+ event["data"] = {"schema": data["schema"], "value": data["value"]}
1376
1549
  return event
1377
1550
 
1378
1551
 
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
5
5
  compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode. Ships in one plugin with adapting-projects-to-fabric, whose scripts it reuses.
6
6
  metadata:
7
7
  author: PassionCode.ai
8
- version: "0.25.0"
8
+ version: "0.26.0"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "a046f57cf46ea9e3da57ff8ce6a3ed4d26d8774b"
11
11
  ---
@@ -18,7 +18,7 @@ compatibility: >-
18
18
  this plugin; Codex and Kimi Code get the session-start preamble hook on the person's consent.
19
19
  metadata:
20
20
  author: PassionCode.ai
21
- version: "0.25.0"
21
+ version: "0.26.0"
22
22
  ---
23
23
 
24
24
  # Working with Fabric
@@ -60,7 +60,7 @@ WATCH_MAX_BACKOFF_S = 900
60
60
  SESSION_ID = re.compile(r"[A-Za-z0-9._:-]{1,128}")
61
61
  LOOPBACK_ORIGIN = re.compile(r"http://127\.0\.0\.1:(\d{1,5})")
62
62
  LOCAL_ORIGIN = re.compile(r'"origin": "http://(?:127\.0\.0\.1|localhost)')
63
- VERSION = "0.25.0"
63
+ VERSION = "0.26.0"
64
64
  HOSTS = {"claude": "Claude Code", "codex": "Codex", "kimi": "Kimi Code"}
65
65
  # A session Fabric launched. FABRIC_LAUNCHED_SESSION=1 is the marker Fabric is asked to set (need D6); the others are
66
66
  # what Fabric already gives a launched session (fabric sessionBundle.ts): only their NAMES are looked at, never a value