@passioncode-ai/fabric-agent-adapter 0.44.0 → 0.44.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  5. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +1 -1
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +9 -6
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +1 -1
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +5 -7
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +13 -11
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +1 -1
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +1 -1
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +5 -11
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +8 -8
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +1 -1
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +263 -67
  25. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  26. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  27. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,45 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.44.2 (2026-10-22)
4
+
5
+ ### Fixed
6
+
7
+ - **The consumed-approval file is written with the kit's private writer** (FAA-85). `FileJtiStore` in both twins
8
+ opened its file with a plain 0600 mode and set no Windows ACL, so on Windows its privacy rested on its folder alone,
9
+ and a write that failed left its temporary file behind. It now writes through `fabric_paths.atomic_write` /
10
+ `atomicWrite`: a protected owner-only ACL before the bytes on Windows (DEC-0032), 0600 elsewhere, fsynced and renamed
11
+ into place, nothing left on failure. The file holds consumed approval ids, not a secret.
12
+ - **The kit's private writer removes its temporary file whatever fails, and reports the error that caused it**
13
+ (FAA-85, found by the blind readings). Node `atomicWrite` cleaned up only when chmod or the rename failed; a failure
14
+ while setting the Windows ACL, writing or syncing left `.<name>.<pid>.<hex>` beside the real file. Python
15
+ `atomic_write` set the Windows ACL before it wrapped the file's handle, so when that step failed the handle stayed
16
+ open, Windows could not delete the file, and the delete's error replaced the ACL's. Both now close the file on every
17
+ path, remove it, and raise the original error even when the removal fails.
18
+
19
+ ### Changed
20
+
21
+ - `fabric_base`, `fabric_platform` and `fabric_paths` (and their Node twins) move from layer L3 to L1 in `kit.json`, so
22
+ the L2 modules may use them: their code and names are unchanged. `fabric_delegation` now imports `fabric_paths`, so
23
+ a service that copies `fabric_delegation` by hand copies `fabric_base`, `fabric_platform` and `fabric_paths` too
24
+ (`fabric_kit.py install` and `status` name them).
25
+
26
+ ## 0.44.1 (2026-10-22)
27
+
28
+ ### Changed
29
+
30
+ - **Two kit modules split by responsibility** (FAA-92). `fabric_interop` and `fabric_package` stood at the 1000-line
31
+ ceiling `test_fabric_kit` enforces. The rules of `fabric-interop/0.1` that need no running service (constants,
32
+ `InteropError`, `UnknownJob`, trace context, the job limits of DEC-0087) moved to `fabric_interop_rules` /
33
+ `fabric-interop-rules.mjs` (L3), and the reading of `fabric-package.schema.json` to `fabric_package_schema` /
34
+ `fabric-package-schema.mjs` (L2), which now carries that schema as its data. `fabric_interop` and `fabric_package`
35
+ re-export every name they had, in both languages, so no caller changes; a service that copies the kit by hand
36
+ copies the two new files too (`fabric_kit.py install` and `status` name them). No behaviour changed.
37
+
38
+ ### Fixed
39
+
40
+ - A board test (`test_a_revision_conflict_on_renew_reads_again_and_keeps_the_attempt`) read the call order before its
41
+ handler's thread had started its effect, and failed under load. It now waits for the effect to start.
42
+
3
43
  ## 0.44.0 (2026-10-22)
4
44
 
5
45
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.44.0",
3
+ "version": "0.44.2",
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.44.0",
6
+ "version": "0.44.2",
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.44.0"
8
+ version: "0.44.2"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0"
11
11
  ---
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); 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.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.44.0"
17
+ version: "0.44.2"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0"
@@ -80,7 +80,7 @@ then the grant check where there is one, then `verify_approval` / `verifyApprova
80
80
  `jti` last, then the action. Without an approval, answer `approval-required` with
81
81
  `action_digest(action)` / `actionDigest(action)` (the SHA-256 of the action's RFC 8785 form, `jcs`) so the caller
82
82
  can ask the hub. When the meter denies a grant check, answer `refusal_from_denial(denial)` /
83
- `refusalFromDenial(denial)`: the meter's code unchanged, its window and level in `error.data` (DL-21 step 6). Keep consumed ids in `FileJtiStore` (0600, survives a restart); `MemoryJtiStore` is for tests
83
+ `refusalFromDenial(denial)`: the meter's code unchanged, its window and level in `error.data` (DL-21 step 6). Keep consumed ids in `FileJtiStore` (owner-only through the kit's private writer: 0600, on Windows a protected ACL; survives a restart); `MemoryJtiStore` is for tests
84
84
  and `UNSAFE_NO_JTI_STORE` exists only by name. No store at all, or a store file that cannot be read, is
85
85
  `unknown-state`, never a pass.
86
86
 
@@ -54,14 +54,15 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
54
54
  | L0 | `fabric_identifiers` | identifier equality by normal form (DEC-0076) |
55
55
  | L0 | `fabric_secrets` | the contract's one credential detector (DEC-0044) |
56
56
  | L1 | `fabric_crypto` | Ed25519 signing (a Montgomery ladder that does not branch on the key) and verification, P-256 verification, base64url, sha256 |
57
+ | L1 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
58
+ | L1 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
59
+ | L1 | `fabric_paths` | the services directory, a service's folders, atomic and private writes (L1 since 0.44.2, so `fabric_delegation` writes its consumed approval ids with them, FAA-85) |
57
60
  | L2 | `fabric_estate` | `estate-record/1` revisions and their rules (DEC-0055) |
58
61
  | L2 | `fabric_delegation` | the callee's side of `fabric-delegation/0.1` (DEC-0039) |
59
62
  | L2 | `fabric_share` | share grants and registrations (DEC-0086) |
60
63
  | L2 | `fabric_ceremonies` | the headless join token (DEC-0084) |
61
- | L2 | `fabric_package` | `fabric-package/1`: pack, sign, verify (DEC-0050, DEC-0051) |
62
- | L3 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
63
- | L3 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
64
- | L3 | `fabric_paths` | the services directory, a service's folders, atomic and private writes |
64
+ | L2 | `fabric_package_schema` | the reading of `fabric-package.schema.json` with JavaScript's pattern and format semantics (FAA-92) |
65
+ | L2 | `fabric_package` | `fabric-package/1`: pack, sign, verify; re-exports `schema_problems` (DEC-0050, DEC-0051) |
65
66
  | L3 | `fabric_protocols` | protocol revisions (DEC-0067) and feed data (DEC-0070) |
66
67
  | L3 | `fabric_connections` | what an agent uses, `fabric-connections/0.1`: the reference reading and the declaration (DEC-0096, FAC-SEM-127) |
67
68
  | L3 | `fabric_lock` | the instance lock and the supervisor marker (LC-01, DEC-0057) |
@@ -74,7 +75,8 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
74
75
  | L3 | `fabric_supervisors` | launchd, systemd user units, Task Scheduler (DEC-0032, LC-14) |
75
76
  | L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
76
77
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
77
- | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools ([interop](interop.md)) |
78
+ | L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92) |
79
+ | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
78
80
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
79
81
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
80
82
  | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data |
@@ -94,7 +96,8 @@ the kit live in `../recipes/` ([the support agent](../recipes/support-agent/READ
94
96
  public name 0.39.1 had, in both languages (the snapshot is `test/fixtures/kit-api/kit-api-0.39.1.json`; a missing
95
97
  name fails `test_kit_api`), and the Python facade the private names other code reached through it (`_number`,
96
98
  `_DAY_MS`, `_utc_date_ms` from the core). `fabric_board` and `fabric-board.mjs` are
97
- facades the same way. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
99
+ facades the same way, and since 0.44.1 so are `fabric_interop` (over `fabric_interop_rules`) and `fabric_package` (over
100
+ `fabric_package_schema`), with their Node twins: patch the rule or schema module, not the facade. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
98
101
  install with `fabric_kit.py install` and `status` names what is missing. A test or service that replaced a function
99
102
  by assigning it on the facade (`fabric_service.X = ...`) no longer changes what the kit does: the code reads its own
100
103
  module's name, so patch the module that defines it (`fabric_lock.X`, `fabric_session_env.X`). A service that imported a module-private
@@ -1,5 +1,5 @@
1
1
  // What every module of the Node service kit shares: the protocol name, ServiceError, ids, nowIso and expand.
2
- // Part of the Node service kit (FAA-65, layer L3); the twin of fabric_base.py. Node 20+, no dependency.
2
+ // Part of the Node service kit (FAA-65, layer L1); the twin of fabric_base.py. Node 20+, no dependency.
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
 
@@ -13,8 +13,8 @@
13
13
  import crypto from 'node:crypto';
14
14
  import fs from 'node:fs';
15
15
  import net from 'node:net';
16
- import path from 'node:path';
17
16
  import { hubKeyCertified } from './fabric-estate.mjs';
17
+ import { atomicWrite } from './fabric-paths.mjs'; // the kit's private writer (FAA-85)
18
18
 
19
19
  export const REFUSAL_CODES = Object.freeze([
20
20
  'unauthenticated', 'token-expired', 'wrong-audience', 'scope-not-granted', 'effect-above-ceiling', 'no-grant',
@@ -347,7 +347,7 @@ export function createMemoryJtiStore() {
347
347
  export class JtiStoreError extends Error {}
348
348
 
349
349
  /**
350
- * Consumed approval ids in a 0600 JSON file, so a second use is refused across a restart (DL-18). A file that
350
+ * Consumed approval ids in an owner-only JSON file, so a second use is refused across a restart (DL-18). A file that
351
351
  * cannot be read throws JtiStoreError: forgetting every consumed id would let each approval be replayed.
352
352
  */
353
353
  export class FileJtiStore {
@@ -364,11 +364,9 @@ export class FileJtiStore {
364
364
  const seen = new Map(Object.entries(read).filter(([, until]) => typeof until === 'number' && until + LEEWAY_SECONDS >= now));
365
365
  if (seen.has(jti)) return false;
366
366
  seen.set(jti, expiresAt);
367
- fs.mkdirSync(path.dirname(this.file), { recursive: true });
368
- const temp = path.join(path.dirname(this.file), `.${path.basename(this.file)}.${crypto.randomBytes(4).toString('hex')}.tmp`);
369
- const fd = fs.openSync(temp, 'wx', 0o600);
370
- try { fs.writeSync(fd, JSON.stringify(Object.fromEntries(seen))); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
371
- fs.renameSync(temp, this.file);
367
+ // Owner-only (0600; on Windows a protected ACL before the bytes, DEC-0032), fsynced, renamed into place, and no
368
+ // temporary file left when it fails (FAA-85).
369
+ atomicWrite(this.file, JSON.stringify(Object.fromEntries(seen)));
372
370
  return true;
373
371
  }
374
372
  }
@@ -0,0 +1,264 @@
1
+ // fabric-interop/0.1, the rule layer: the extension's names and limits, its errors, the trace context (C3.4), a usage
2
+ // object's reading (C3.2) and the job limits and uncertain effects (DEC-0087). Every function answers by a rule and
3
+ // touches no file; fabric-interop.mjs builds the tools, the envelope and the job store on it and re-exports every public
4
+ // name here. Part of the Node service kit (FAA-92, split from fabric-interop.mjs; layer L3); the twin of
5
+ // fabric_interop_rules.py. Node 20+, no dependency.
6
+ import crypto from 'node:crypto';
7
+ import { ServiceError } from './fabric-base.mjs';
8
+ import { jcs } from './fabric-delegation.mjs';
9
+ import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
10
+ import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
11
+
12
+ export const PROTOCOL = 'fabric-interop/0.1';
13
+ export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
14
+ export const MCP_REVISION = '2026-07-28';
15
+ export const TERMINAL = ['completed', 'failed', 'cancelled'];
16
+ export const CONTRACT_VERSION = '0.1.0';
17
+ export const OUTCOMES = ['succeeded', 'partial', 'failed', 'cancelled', 'blocked'];
18
+ export const ENVELOPE_REQUIRED = ['id', 'contractVersion', 'outcome', 'done', 'proof', 'scope', 'notVerified', 'artifacts', 'createdAt', 'producer', 'output', 'usage'];
19
+ // The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
20
+ export const JOB_HANDLE_SCHEMA = {
21
+ type: 'object', required: ['job'], additionalProperties: false,
22
+ properties: { job: { type: 'object', required: ['id', 'status'], additionalProperties: false,
23
+ properties: { id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }, status: { const: 'working' } } } },
24
+ };
25
+
26
+ export const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
27
+ export const JOB_ID = /^[A-Za-z0-9._:-]{1,128}$/;
28
+ export const KEY = /^[A-Za-z0-9_.-]{1,64}$/;
29
+ export const SECRET_WORDS = /pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential/i;
30
+ export const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'costUsd', 'wallMs'];
31
+
32
+ export class InteropError extends ServiceError {}
33
+ export class UnknownJob extends InteropError {
34
+ constructor(jobId) { super(`unknown-job: no job ${jobId} here.`); this.jobId = jobId; }
35
+ }
36
+
37
+ // --- C3.4 trace context ---------------------------------------------------------------
38
+ export function parseTraceparent(value) {
39
+ const m = typeof value === 'string' ? TRACEPARENT.exec(value) : null;
40
+ if (!m) return null;
41
+ const [, version, traceId, spanId, flags] = m;
42
+ if (version === 'ff' || /^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
43
+ return { version, traceId, spanId, flags };
44
+ }
45
+
46
+ export const spanIdNew = () => { for (;;) { const v = crypto.randomBytes(8).toString('hex'); if (!/^0+$/.test(v)) return v; } };
47
+
48
+ export function childTraceparent(parent) {
49
+ const p = parseTraceparent(parent);
50
+ return `00-${p ? p.traceId : crypto.randomBytes(16).toString('hex')}-${spanIdNew()}-${p ? p.flags : '01'}`;
51
+ }
52
+
53
+ export const traceIds = (traceparent) => {
54
+ const p = parseTraceparent(traceparent);
55
+ return p ? { traceId: p.traceId, spanId: p.spanId } : {};
56
+ };
57
+
58
+ export function checkUsage(usage, nulls = false) {
59
+ if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
60
+ for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
61
+ for (const [key, value] of Object.entries(usage)) {
62
+ if (nulls && key === 'partial') {
63
+ if (typeof value !== 'boolean') throw new InteropError('usage.partial must be a boolean.');
64
+ continue;
65
+ }
66
+ if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
67
+ if (nulls && value === null) continue;
68
+ const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
69
+ if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
70
+ }
71
+ if (nulls && usageFindings(usage, '').length) throw new InteropError('usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).');
72
+ return { ...usage };
73
+ }
74
+ // --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
75
+ // #region job-limits-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
76
+ // The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json), the twin of
77
+ // fabric_interop.py. Every function here answers and never throws on malformed input.
78
+
79
+ export const QUOTA_EXHAUSTED = 'quota-exhausted';
80
+ export const OUTCOME_UNKNOWN = 'outcome-unknown';
81
+ export const LIMIT_REACHED = 'limit-reached';
82
+ export const INTEROP_NULL_USAGE = 'fabric-interop/0.2';
83
+ /** The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087). */
84
+ export const PROTOCOLS_READ = Object.freeze(['fabric-interop/0.1', INTEROP_NULL_USAGE]);
85
+ export const JOB_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
86
+ export const UTC_INSTANT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z$/;
87
+ export const PAUSE = /^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?$/;
88
+ export const ERROR_CODE = /^[a-z][a-z0-9-]{1,63}$/;
89
+ export const MAX_STATUS_MESSAGE = 500;
90
+ export const MAX_ERROR_MESSAGE = 2000;
91
+ export const codePoints = (value) => [...value].length;
92
+ export const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
93
+
94
+ /** A UTC instant as milliseconds, or NaN: the pattern and a day that exists, as the contract's strictTime reads it. */
95
+ export function utcTime(value) {
96
+ const m = isString(value) ? UTC_INSTANT.exec(value) : null;
97
+ if (!m) return Number.NaN;
98
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
99
+ const date = new Date(0);
100
+ date.setUTCFullYear(year, month - 1, day);
101
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59) return Number.NaN;
102
+ date.setUTCHours(hour, minute, second, Number((m[7] ?? '').padEnd(3, '0')));
103
+ return date.getTime();
104
+ }
105
+
106
+ export function quotaDataOk(data) {
107
+ const retryable = own(data, 'retryable');
108
+ if (!isObject(data) || typeof retryable !== 'boolean') return false;
109
+ if (Object.keys(data).some((name) => name !== 'retryable' && name !== 'resetsAt')) return false;
110
+ if (!Object.hasOwn(data, 'resetsAt')) return true;
111
+ return retryable && Number.isFinite(utcTime(own(data, 'resetsAt')));
112
+ }
113
+
114
+ export function jobOf(answer) {
115
+ const job = own(answer, 'job');
116
+ const id = own(job, 'id');
117
+ if (!isObject(job) || !isString(id) || id.length === 0 || !JOB_STATUSES.includes(own(job, 'status'))) return undefined;
118
+ return job;
119
+ }
120
+
121
+ export function readJobPause(answer) {
122
+ const job = jobOf(answer);
123
+ const message = own(job, 'statusMessage');
124
+ if (!job || own(job, 'status') !== 'working' || !isString(message) || !message.startsWith(QUOTA_EXHAUSTED)) return { paused: false };
125
+ const match = PAUSE.exec(message);
126
+ if (!match || codePoints(message) > MAX_STATUS_MESSAGE) return { paused: false, malformed: true };
127
+ const until = match[1];
128
+ if (until === undefined) return { paused: true };
129
+ return Number.isFinite(utcTime(until)) ? { paused: true, resetsAt: until } : { paused: false, malformed: true };
130
+ }
131
+
132
+ /** What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`; never "submit anew". */
133
+ export function jobCallerAction(answer, now) {
134
+ const at = utcTime(now);
135
+ const job = jobOf(answer);
136
+ if (!Number.isFinite(at) || !job) return { action: 'invalid' };
137
+ const status = own(job, 'status');
138
+ if (status === 'working') {
139
+ const pause = readJobPause(answer);
140
+ if ('malformed' in pause) return { action: 'invalid' };
141
+ if (!pause.paused) return { action: 'poll' };
142
+ if (pause.resetsAt === undefined) return { action: 'wait' };
143
+ return utcTime(pause.resetsAt) > at ? { action: 'wait', notBefore: pause.resetsAt } : { action: 'poll' };
144
+ }
145
+ if (status === 'input_required') return { action: 'answer' };
146
+ if (status === 'completed') return { action: 'done' };
147
+ if (status === 'cancelled') return { action: 'cancelled' };
148
+ const error = own(job, 'error');
149
+ const code = own(error, 'code');
150
+ const message = own(error, 'message');
151
+ const codeOk = (isString(code) && ERROR_CODE.test(code)) || Number.isInteger(code);
152
+ const shapeOk = isObject(error) && Object.keys(error).every((name) => name === 'code' || name === 'message' || name === 'data');
153
+ if (!shapeOk || !codeOk || !isString(message) || message.length === 0 || codePoints(message) > MAX_ERROR_MESSAGE) return { action: 'invalid' };
154
+ if (code === OUTCOME_UNKNOWN) return { action: 'reconcile' };
155
+ if (code === LIMIT_REACHED) return { action: 'person' };
156
+ if (code !== QUOTA_EXHAUSTED) return { action: 'failed' };
157
+ const data = own(error, 'data');
158
+ if (!quotaDataOk(data)) return { action: 'invalid' };
159
+ if (!data.retryable) return { action: 'person' };
160
+ return data.resetsAt !== undefined && utcTime(data.resetsAt) > at ? { action: 'resend', notBefore: data.resetsAt } : { action: 'resend' };
161
+ }
162
+
163
+ /** The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 over its RFC 8785 form. */
164
+ export function intentIdempotencyKey(intent) {
165
+ const names = ['agentId', 'capability', 'input', 'request'];
166
+ if (!isObject(intent) || Object.keys(intent).some((name) => !names.includes(name))) return undefined;
167
+ const [agentId, capability, input, request] = names.map((name) => own(intent, name));
168
+ if (!isString(agentId) || agentId.length === 0 || !isString(capability) || capability.length === 0 || !isObject(input)) return undefined;
169
+ if (Object.hasOwn(intent, 'request') && (!isString(request) || request.length === 0 || codePoints(request) > 256)) return undefined;
170
+ let form;
171
+ try {
172
+ form = jcs(Object.hasOwn(intent, 'request') ? { agentId, capability, input, request } : { agentId, capability, input });
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ const bytes = crypto.createHash('sha256').update(form, 'utf8').digest().subarray(0, 16);
177
+ bytes[6] = (bytes[6] & 0x0f) | 0x80;
178
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
179
+ const hex = bytes.toString('hex');
180
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
181
+ }
182
+
183
+ export function readsNullUsage(requestMeta) {
184
+ const protocols = own(requestMeta, 'protocols');
185
+ return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
186
+ }
187
+
188
+ /** A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087). */
189
+ export function requestMeta(traceparent) {
190
+ const meta = { protocols: [...PROTOCOLS_READ] };
191
+ if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
192
+ return meta;
193
+ }
194
+
195
+ /** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
196
+ export function usageFindings(usage, at) {
197
+ if (!isObject(usage) || usage.partial !== false) return [];
198
+ const unknown = USAGE_KEYS.filter((name) => Object.hasOwn(usage, name) && usage[name] === null);
199
+ if (unknown.length === 0) return [];
200
+ return [{ code: 'FAC-SEM-119', instancePath: `${at}/partial`, message: `partial is false while ${unknown.join(', ')} is null: an unknown number makes the usage partial` }];
201
+ }
202
+
203
+ /** The usage a provider writes toward a caller: null and partial toward a 0.2 reader, the measured lower bound otherwise. */
204
+ export function usageToward(usage, meta) {
205
+ if (readsNullUsage(meta)) {
206
+ const checked = checkUsage(usage, true);
207
+ if (USAGE_KEYS.some((name) => checked[name] === null) && !('partial' in checked)) checked.partial = true;
208
+ return checked;
209
+ }
210
+ if (!isObject(usage)) throw new InteropError('usage must be an object.');
211
+ return checkUsage(Object.fromEntries(Object.entries(usage).filter(([key]) => key !== 'partial').map(([key, value]) => [key, value === null ? 0 : value])));
212
+ }
213
+
214
+ export function jobFindings(value) {
215
+ const findings = [];
216
+ if ('malformed' in readJobPause(value)) {
217
+ findings.push({ code: 'FAC-SEM-119', instancePath: '/job/statusMessage', message: "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters" });
218
+ }
219
+ const error = own(own(value, 'job'), 'error');
220
+ const data = own(error, 'data');
221
+ if (own(error, 'code') === QUOTA_EXHAUSTED && isString(own(data, 'resetsAt')) && !Number.isFinite(utcTime(own(data, 'resetsAt')))) {
222
+ findings.push({ code: 'FAC-SEM-119', instancePath: '/job/error/data/resetsAt', message: 'resetsAt is a UTC instant on a day that exists' });
223
+ }
224
+ findings.push(...usageFindings(own(own(own(value, 'job'), 'result'), 'usage'), '/job/result/usage'));
225
+ return findings;
226
+ }
227
+
228
+ export const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
229
+
230
+ /** FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller); undefined for another kind. */
231
+ export function jobLimitRules(kind, value) {
232
+ if (kind === 'interop-job') return jobFindings(value);
233
+ if (kind === 'usage') return usageFindings(value, '');
234
+ if (kind === 'result') return usageFindings(own(value, 'usage'), '/usage');
235
+ if (kind === 'job-caller') {
236
+ const expected = jobCallerAction(own(value, 'answer'), own(value, 'now'));
237
+ return sameJson(own(value, 'action'), expected) ? [] : [{ code: 'FAC-SEM-120', instancePath: '/action', message: `the caller answers ${JSON.stringify(expected)} as jobCallerAction does` }];
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /** A paused job's statusMessage in the pause grammar; throws InteropError for one a reader would call malformed. */
243
+ export function pauseMessage({ resetsAt, text } = {}) {
244
+ const message = `${QUOTA_EXHAUSTED}${resetsAt === undefined ? '' : ` until ${resetsAt}`}${text === undefined ? '' : `: ${text}`}`;
245
+ if ('malformed' in readJobPause({ job: { id: 'x', status: 'working', statusMessage: message } })) {
246
+ throw new InteropError('A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.');
247
+ }
248
+ return message;
249
+ }
250
+
251
+ /** The provider's answer to a repeated key: 'run' (the intent runs again, once) or 'same' (answer with that job). */
252
+ export function repeatedKeyDecision(job, now, { limitHolds, approvalGranted = false } = {}) {
253
+ if (!isObject(job) || job.status !== 'failed') return 'same';
254
+ const error = own(job, 'error');
255
+ const code = own(error, 'code');
256
+ if (code === LIMIT_REACHED) return approvalGranted === true ? 'run' : 'same';
257
+ if (code !== QUOTA_EXHAUSTED) return 'same';
258
+ if (limitHolds !== undefined && limitHolds !== null) return limitHolds === false ? 'run' : 'same';
259
+ const data = own(error, 'data');
260
+ const at = utcTime(now);
261
+ if (!quotaDataOk(data) || !data.retryable || !Number.isFinite(at)) return 'same';
262
+ return data.resetsAt === undefined || utcTime(data.resetsAt) <= at ? 'run' : 'same';
263
+ }
264
+ // #endregion job-limits-kit-node