@warpgogol/werkstatt-shared 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +18 -1
  2. package/src/agent/api-catalog.ts +14 -1
  3. package/src/agent/guidance.ts +52 -0
  4. package/src/agent/health.ts +68 -0
  5. package/src/agent/index.ts +5 -1
  6. package/src/agent/manifest.ts +70 -7
  7. package/src/agent/openapi.ts +97 -4
  8. package/src/agent/receipt.ts +123 -0
  9. package/src/agent/search.test.ts +3 -0
  10. package/src/agent/search.ts +25 -4
  11. package/src/agent/tests/api-catalog.test.ts +6 -0
  12. package/src/agent/tests/ard-catalog.test.ts +9 -0
  13. package/src/agent/tests/manifest.test.ts +71 -0
  14. package/src/agent/tests/mcp-card.test.ts +4 -0
  15. package/src/agent/tests/openapi.test.ts +117 -0
  16. package/src/component/index.ts +3 -1
  17. package/src/fingerprint/index.ts +3 -1
  18. package/src/integration/idempotency.ts +88 -0
  19. package/src/integration/index.ts +4 -0
  20. package/src/integration/tests/idempotency.test.ts +81 -0
  21. package/src/kernel/drift-guard.ts +166 -0
  22. package/src/kernel/index.ts +8 -1
  23. package/src/kernel/tests/drift-guard.test.ts +112 -0
  24. package/src/kernel/tests/workpiece-env.test.ts +44 -0
  25. package/src/kernel/types.ts +63 -4
  26. package/src/kernel/workpiece-env.ts +48 -0
  27. package/src/middleware/access-protection.ts +3 -0
  28. package/src/middleware/language-redirect.ts +3 -0
  29. package/src/observability/index.ts +8 -0
  30. package/src/observability/pipeline-log.ts +81 -0
  31. package/src/ontology/capabilities/lead.prepare.yaml +44 -0
  32. package/src/ontology/capabilities/lead.submit.yaml +20 -3
  33. package/src/ontology/operations/index.ts +6 -0
  34. package/src/ontology/operations/notausgang.ts +44 -0
  35. package/src/ontology/schemas/capability.ts +55 -3
  36. package/src/passport/claim-sign.ts +120 -0
  37. package/src/passport/index.ts +3 -0
  38. package/src/plugin/plugin-contract.ts +1 -1
  39. package/src/semantic/canonical-uri.ts +28 -8
  40. package/src/semantic/llms.ts +10 -2
  41. package/src/semantic/tests/canonical-uri.test.ts +18 -6
  42. package/src/signing/index.ts +3 -1
  43. package/src/stack/run-tool.ts +2 -4
@@ -0,0 +1,81 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>Provides lightweight structured pipeline log events for build-time content helpers.</purpose>
4
+ <non-goals>
5
+ <item>Do not persist logs outside the current Node.js process.</item>
6
+ <item>Do not silence actionable warnings or errors.</item>
7
+ </non-goals>
8
+ </MODULE_CONTRACT>
9
+ <CHANGE_SUMMARY>
10
+ <item>RFC-0254: add structured expected-fallback log transport for build/check paths.</item>
11
+ </CHANGE_SUMMARY>
12
+ */
13
+
14
+ export type PipelineLogSeverity = "debug" | "info" | "notice" | "warning" | "error";
15
+
16
+ export type PipelineLogKind =
17
+ "progress" | "expected-fallback" | "advisory" | "external-tool" | "diagnostic" | "error";
18
+
19
+ export interface PipelineLogEvent {
20
+ severity: PipelineLogSeverity;
21
+ kind: PipelineLogKind;
22
+ message: string;
23
+ command?: string;
24
+ pipeline?: string;
25
+ app?: string;
26
+ packageName?: string;
27
+ module?: string;
28
+ file?: string;
29
+ line?: number;
30
+ ruleId?: string;
31
+ dedupeKey?: string;
32
+ count?: number;
33
+ data?: Record<string, unknown>;
34
+ }
35
+
36
+ interface PipelineLogState {
37
+ events: PipelineLogEvent[];
38
+ counts: Map<string, number>;
39
+ }
40
+
41
+ const STATE_KEY = "__gogolPipelineLogState";
42
+
43
+ function getState(): PipelineLogState {
44
+ const root = globalThis as typeof globalThis & { __gogolPipelineLogState?: PipelineLogState };
45
+ if (!root[STATE_KEY]) {
46
+ root[STATE_KEY] = { events: [], counts: new Map() };
47
+ }
48
+ return root[STATE_KEY];
49
+ }
50
+
51
+ function renderEvent(event: PipelineLogEvent, count: number): string {
52
+ const prefix = event.kind === "expected-fallback" ? "fallback" : event.kind;
53
+ const suffix = count > 1 ? ` (${count} occurrence(s))` : "";
54
+ return `[${event.severity}:${prefix}] ${event.message}${suffix}`;
55
+ }
56
+
57
+ export function emitPipelineLogEvent(event: PipelineLogEvent): void {
58
+ const state = getState();
59
+ const key = event.dedupeKey ?? `${event.kind}:${event.severity}:${event.message}`;
60
+ const count = (state.counts.get(key) ?? 0) + 1;
61
+ state.counts.set(key, count);
62
+ state.events.push({ ...event, count });
63
+
64
+ if (event.severity === "debug") return;
65
+ if (event.kind === "expected-fallback" && count > 1) return;
66
+
67
+ const rendered = renderEvent(event, count);
68
+ if (event.severity === "error") {
69
+ console.error(rendered);
70
+ return;
71
+ }
72
+ if (event.severity === "warning") {
73
+ console.warn(rendered);
74
+ return;
75
+ }
76
+ console.log(rendered);
77
+ }
78
+
79
+ export function getPipelineLogEvents(): PipelineLogEvent[] {
80
+ return getState().events.map((event) => ({ ...event }));
81
+ }
@@ -0,0 +1,44 @@
1
+ id: lead.prepare
2
+ version: 1
3
+ kind: action
4
+ sideEffect: none
5
+ title:
6
+ de: Anfrage prüfen
7
+ en: Preview a lead
8
+ description:
9
+ de: "Prüft eine Kontaktanfrage (Name, E-Mail, Nachricht) ohne sie abzusenden — liefert eine draftId, die als Idempotency-Key eines späteren lead.submit dient. Nebenwirkungsfrei: es wird nichts zugestellt."
10
+ en: "Validates a contact request (name, email, message) without submitting it — returns a draftId usable as the Idempotency-Key of a later lead.submit. Side-effect-free: nothing is dispatched."
11
+ input:
12
+ type: object
13
+ required: [name, email, message]
14
+ additionalProperties: false
15
+ properties:
16
+ name:
17
+ type: string
18
+ minLength: 1
19
+ maxLength: 200
20
+ email:
21
+ type: string
22
+ format: email
23
+ maxLength: 254
24
+ message:
25
+ type: string
26
+ minLength: 1
27
+ maxLength: 5000
28
+ output:
29
+ type: object
30
+ required: [valid, draftId]
31
+ additionalProperties: false
32
+ properties:
33
+ valid:
34
+ type: boolean
35
+ draftId:
36
+ type: string
37
+ minLength: 71
38
+ maxLength: 71
39
+ requires:
40
+ entitlements: [agent.actions]
41
+ sections: [send-message]
42
+ limits:
43
+ perMinutePerIp: 10
44
+ maxPayloadBytes: 10240
@@ -1,6 +1,7 @@
1
1
  id: lead.submit
2
- version: 1
2
+ version: 2
3
3
  kind: action
4
+ sideEffect: write
4
5
  title:
5
6
  de: Anfrage senden
6
7
  en: Submit a lead
@@ -26,11 +27,22 @@ input:
26
27
  maxLength: 5000
27
28
  output:
28
29
  type: object
29
- required: [accepted]
30
+ required: [receiptId, status, duplicate, submittedAt]
30
31
  additionalProperties: false
31
32
  properties:
32
- accepted:
33
+ receiptId:
34
+ type: string
35
+ format: uuid
36
+ status:
37
+ type: string
38
+ minLength: 1
39
+ maxLength: 16
40
+ duplicate:
33
41
  type: boolean
42
+ submittedAt:
43
+ type: string
44
+ minLength: 20
45
+ maxLength: 32
34
46
  integration:
35
47
  eventKind: lead
36
48
  source: agent
@@ -42,3 +54,8 @@ humanEquivalent:
42
54
  limits:
43
55
  perMinutePerIp: 3
44
56
  maxPayloadBytes: 10240
57
+ agentGuidance:
58
+ sideEffect: write
59
+ whenToUse: "a visitor wants to contact the business — a question, a request, or a booking inquiry that needs a human reply."
60
+ whenNotToUse: "the visitor only needs information already published on the site — answer from the knowledge files or search instead of creating a lead."
61
+ retrySemantics: "safe to retry with the same Idempotency-Key — a repeated submission returns the original receipt instead of creating a duplicate lead."
@@ -158,11 +158,17 @@ export {
158
158
  integrationManifestSchema,
159
159
  integrationNullingSchema,
160
160
  notausgangManifestSchema,
161
+ notausgangEvidenceArtifactSchema,
162
+ notausgangEvidenceVerdictSchema,
163
+ notausgangFeatureNoteSchema,
161
164
  } from "./notausgang.ts";
162
165
  export type {
163
166
  IntegrationSecretLocation,
164
167
  IntegrationManifest,
165
168
  IntegrationNulling,
169
+ NotausgangEvidenceArtifact,
170
+ NotausgangEvidenceVerdict,
171
+ NotausgangFeatureNote,
166
172
  NotausgangManifest,
167
173
  } from "./notausgang.ts";
168
174
 
@@ -36,6 +36,36 @@ export const integrationNullingSchema = z.object({
36
36
  ),
37
37
  });
38
38
 
39
+ // RFC-1120: per-artifact record inside an evidence bundle verdict
40
+ export const notausgangEvidenceArtifactSchema = z.object({
41
+ itemKey: z.string().min(1),
42
+ filename: z.string().min(1).optional(),
43
+ sha256: z
44
+ .string()
45
+ .regex(/^[a-f0-9]{64}$/)
46
+ .optional(),
47
+ recordedSha256: z
48
+ .string()
49
+ .regex(/^[a-f0-9]{64}$/)
50
+ .optional(),
51
+ r2Path: z.string().min(1).optional(),
52
+ external: z.boolean().optional(),
53
+ url: z.string().min(1).optional(),
54
+ });
55
+
56
+ // RFC-1120: per-source verdict — "bundled" | "external-reference" | "excluded:<reason>"
57
+ export const notausgangEvidenceVerdictSchema = z.object({
58
+ verdict: z.string().regex(/^(bundled|external-reference|excluded:.+)$/),
59
+ artifacts: z.array(notausgangEvidenceArtifactSchema).optional(),
60
+ });
61
+
62
+ // RFC-1120: dynamic-feature disclosure entry
63
+ export const notausgangFeatureNoteSchema = z.object({
64
+ feature: z.string().min(1),
65
+ verdict: z.enum(["static-ok", "frozen-at-export", "needs-backend"]),
66
+ detail: z.string(),
67
+ });
68
+
39
69
  export const notausgangManifestSchema = z.object({
40
70
  schemaVersion: z.string().min(1),
41
71
  systemId: z.string().regex(STERNSYSTEM_ID_REGEX),
@@ -56,9 +86,23 @@ export const notausgangManifestSchema = z.object({
56
86
  distHash: z.string(),
57
87
  siteHash: z.string(),
58
88
  bordbuchHash: z.string(),
89
+ // RFC-1120: optional in schema (granular NA-* rules report absence);
90
+ // mandatory in packages produced by notausgang.export.
91
+ evidence: z.record(z.string(), notausgangEvidenceVerdictSchema).optional(),
92
+ features: z.array(notausgangFeatureNoteSchema).optional(),
93
+ verifier: z
94
+ .object({
95
+ script: z.literal("verify.py"),
96
+ runtime: z.literal("python3-stdlib"),
97
+ manifestFile: z.literal("notausgang-manifest.json"),
98
+ })
99
+ .optional(),
59
100
  });
60
101
 
61
102
  export type IntegrationSecretLocation = z.infer<typeof integrationSecretLocationSchema>;
62
103
  export type IntegrationManifest = z.infer<typeof integrationManifestSchema>;
63
104
  export type IntegrationNulling = z.infer<typeof integrationNullingSchema>;
105
+ export type NotausgangEvidenceArtifact = z.infer<typeof notausgangEvidenceArtifactSchema>;
106
+ export type NotausgangEvidenceVerdict = z.infer<typeof notausgangEvidenceVerdictSchema>;
107
+ export type NotausgangFeatureNote = z.infer<typeof notausgangFeatureNoteSchema>;
64
108
  export type NotausgangManifest = z.infer<typeof notausgangManifestSchema>;
@@ -15,6 +15,8 @@ lossless projection to both OpenAPI (RFC-0289) and MCP tool schemas (RFC-0290).
15
15
  </MODULE_CONTRACT>
16
16
  <CHANGE_SUMMARY>
17
17
  <item>RFC-0288: initial capability record schema.</item>
18
+ <item>RFC-1112: add sideEffect ("write"|"none"); integration + humanEquivalent become optional and are required unless sideEffect is "none".</item>
19
+ <item>RFC-1115: add optional agentGuidance block (sideEffect, whenToUse, whenNotToUse, retrySemantics) rendered into MCP tool descriptions and OpenAPI operation docs.</item>
18
20
  </CHANGE_SUMMARY>
19
21
  */
20
22
 
@@ -80,6 +82,22 @@ const capabilityLimitsSchema = z
80
82
  })
81
83
  .strict();
82
84
 
85
+ /**
86
+ * RFC-1115: model-oriented guidance rendered into MCP tool descriptions and
87
+ * OpenAPI operation docs. `sideEffect` restates the record-level sideEffect so
88
+ * the guidance block is self-contained — a superRefine keeps them consistent.
89
+ * `retrySemantics` describes the RFC-1112 Idempotency-Key contract
90
+ * descriptively; the mechanism stays owned by RFC-1112.
91
+ */
92
+ const capabilityAgentGuidanceSchema = z
93
+ .object({
94
+ sideEffect: z.enum(["write", "none"]),
95
+ whenToUse: z.string().min(1),
96
+ whenNotToUse: z.string().min(1).optional(),
97
+ retrySemantics: z.string().min(1).optional(),
98
+ })
99
+ .strict();
100
+
83
101
  /** RFC-0288: one capability catalog record (packages/werkstatt-site/src/domain/ontology/capabilities/<id>.yaml). */
84
102
  export const capabilityRecordSchema = z
85
103
  .object({
@@ -90,15 +108,49 @@ export const capabilityRecordSchema = z
90
108
  /** Integer; bump on breaking input/output change (by RFC). */
91
109
  version: z.number().int().positive(),
92
110
  kind: z.literal("action"),
111
+ /**
112
+ * RFC-1112: "write" (default when absent) dispatches an IntegrationEvent;
113
+ * "none" marks a side-effect-free preview that validates input and returns
114
+ * a draftId without dispatching.
115
+ */
116
+ sideEffect: z.enum(["write", "none"]).optional(),
93
117
  title: localizedStringSchema,
94
118
  description: localizedStringSchema,
95
119
  input: capabilityInputOutputSchema,
96
120
  output: capabilityInputOutputSchema,
97
- integration: capabilityIntegrationSchema,
121
+ integration: capabilityIntegrationSchema.optional(),
98
122
  requires: capabilityRequiresSchema,
99
- humanEquivalent: capabilityHumanEquivalentSchema,
123
+ humanEquivalent: capabilityHumanEquivalentSchema.optional(),
100
124
  limits: capabilityLimitsSchema,
125
+ agentGuidance: capabilityAgentGuidanceSchema.optional(),
101
126
  })
102
- .strict();
127
+ .strict()
128
+ .superRefine((record, ctx) => {
129
+ // RFC-1115: agentGuidance.sideEffect must restate the effective record-level
130
+ // sideEffect (absent means "write") — contradictory declarations are drift.
131
+ const effectiveSideEffect = record.sideEffect ?? "write";
132
+ if (record.agentGuidance && record.agentGuidance.sideEffect !== effectiveSideEffect) {
133
+ ctx.addIssue({
134
+ code: "custom",
135
+ path: ["agentGuidance", "sideEffect"],
136
+ message: `must match the record-level sideEffect ("${effectiveSideEffect}")`,
137
+ });
138
+ }
139
+ if (record.sideEffect === "none") return;
140
+ if (!record.integration) {
141
+ ctx.addIssue({
142
+ code: "custom",
143
+ path: ["integration"],
144
+ message: 'required unless sideEffect is "none"',
145
+ });
146
+ }
147
+ if (!record.humanEquivalent) {
148
+ ctx.addIssue({
149
+ code: "custom",
150
+ path: ["humanEquivalent"],
151
+ message: 'required unless sideEffect is "none"',
152
+ });
153
+ }
154
+ });
103
155
 
104
156
  export type CapabilityRecord = z.infer<typeof capabilityRecordSchema>;
@@ -0,0 +1,120 @@
1
+ /*
2
+ <MODULE_CONTRACT>
3
+ <purpose>
4
+ RFC-1124: Fleet claim signing and verification — canonicalization and
5
+ sign/verify wrappers for FleetClaim, built on the existing Ed25519
6
+ signBytes/verifyBytes primitives from sign.ts. Analogous to dht-sign.ts
7
+ but for the git-native fleet claims registry claim shape.
8
+ </purpose>
9
+ <non-goals>
10
+ <item>Do not handle key storage or management — private keys arrive as env vars.</item>
11
+ <item>Do not assemble W3C VC envelopes — claims use a simple signature string, not VCProof.</item>
12
+ <item>Do not modify sign.ts — this module wraps signBytes/verifyBytes.</item>
13
+ <item>Do not implement claim legitimacy rules (self-claim vs transfer claim) — that lives in the engine's claims-repo.</item>
14
+ </non-goals>
15
+ </MODULE_CONTRACT>
16
+ <CHANGE_SUMMARY>
17
+ <item>RFC-1124: initial fleet claim signing module — FleetClaim contract, claimBytes, signClaim, verifyClaim.</item>
18
+ </CHANGE_SUMMARY>
19
+ */
20
+
21
+ /**
22
+ * @warpgogol/werkstatt-shared/passport/claim-sign — fleet claim signing (RFC-1124)
23
+ *
24
+ * Uses signBytes/verifyBytes from sign.ts with a dedicated canonicalization
25
+ * function for fleet claims. The signature field is excluded from
26
+ * canonicalization (it is computed over the claim data without the signature).
27
+ */
28
+
29
+ import { fromHex } from "@warpgogol/werkstatt-shared/signing";
30
+ import { signBytes, toMultibase, verifyBytes } from "./sign.ts";
31
+
32
+ /**
33
+ * One signed claim file: claims/<system-id>.json in the fleet claims repo.
34
+ *
35
+ * The claim is self-contained for offline signature verification:
36
+ * `signerPublicKey` is embedded, so a verifier needs nothing but the claim
37
+ * file to check the signature. Legitimacy (is this signer allowed to claim?)
38
+ * is decided by the engine — self-claims sign with the creator key, transfer
39
+ * claims sign with the PREVIOUS creator's key and carry `authorizationHash`
40
+ * binding them to the bordbuch `handover` event.
41
+ */
42
+ export interface FleetClaim {
43
+ /** SHA-256 of the canonical passport.json at claim time. */
44
+ passportHash: string;
45
+ systemId: string;
46
+ /** did:web-style creator identity from passport.creator.identity. */
47
+ creatorIdentity: string;
48
+ /** deriveInstanceId(creator.publicKey) — first 16 hex of sha256. */
49
+ instanceId: string;
50
+ /** ISO 8601 — LWW field. */
51
+ claimedAt: string;
52
+ /** Bordbuch tip hash at claim time. */
53
+ bordbuchHead: string;
54
+ /**
55
+ * Tombstone marker — non-null ONLY on a tombstone file: a claim explicitly
56
+ * superseded without a same-id successor (e.g. system-id migration).
57
+ * Normal replacement never writes a tombstone — the new claim replaces the
58
+ * file and git history is the supersession chain.
59
+ */
60
+ supersededBy: string | null;
61
+ /**
62
+ * Hex Ed25519 public key of the signer — same format as
63
+ * passport.creator.publicKey, so deriveInstanceId(signerPublicKey) matches
64
+ * instanceId for self-claims. Embedded so the claims repo is self-contained
65
+ * for offline verification. Equals the creator's key for self-claims;
66
+ * equals the PREVIOUS creator's key for transfer claims.
67
+ */
68
+ signerPublicKey: string;
69
+ /**
70
+ * Transfer claims only: sha256 of the handover-authorization.json that
71
+ * authorizes the creator change (matches bordbuch `handover` event
72
+ * metadata.authorizationHash). Null for self-claims.
73
+ */
74
+ authorizationHash: string | null;
75
+ /** Ed25519 signature over the canonical JSON of all fields above. */
76
+ signature: string;
77
+ }
78
+
79
+ /**
80
+ * FleetClaim without the signature field — the data that is actually signed.
81
+ */
82
+ export type FleetClaimData = Omit<FleetClaim, "signature">;
83
+
84
+ /**
85
+ * Produce canonical UTF-8 bytes from a fleet claim (excluding signature).
86
+ * DETERMINISM: identical inputs → identical bytes (sorted-key JSON, no whitespace).
87
+ */
88
+ export function claimBytes(claim: FleetClaimData): Uint8Array {
89
+ const sorted: Record<string, unknown> = {};
90
+ for (const key of Object.keys(claim).sort()) {
91
+ sorted[key] = (claim as Record<string, unknown>)[key];
92
+ }
93
+ const canonical = JSON.stringify(sorted);
94
+ return new TextEncoder().encode(canonical);
95
+ }
96
+
97
+ /**
98
+ * Sign a fleet claim and return a multibase Ed25519 signature string.
99
+ *
100
+ * @param claim — claim data (without signature)
101
+ * @param privateKeyHex — 32-byte Ed25519 private key as hex (from env secret)
102
+ * @returns multibase base58btc signature string
103
+ */
104
+ export async function signClaim(claim: FleetClaimData, privateKeyHex: string): Promise<string> {
105
+ return signBytes(privateKeyHex, claimBytes(claim));
106
+ }
107
+
108
+ /**
109
+ * Verify a fleet claim signature against the claim's embedded signerPublicKey.
110
+ *
111
+ * @param claim — full fleet claim (including signature)
112
+ * @returns true if signature is valid
113
+ */
114
+ export async function verifyClaim(claim: FleetClaim): Promise<boolean> {
115
+ const { signature, ...claimData } = claim;
116
+ // signerPublicKey is hex (passport.creator.publicKey format); verifyBytes
117
+ // expects multibase — convert here so callers never juggle encodings.
118
+ const signerMultibase = toMultibase(fromHex(claim.signerPublicKey));
119
+ return verifyBytes(signerMultibase, claimBytes(claimData), signature);
120
+ }
@@ -56,3 +56,6 @@ export type { IdentityCredentialSubject } from "./identity-sign.ts";
56
56
 
57
57
  export { dhtEntryBytes, signDhtEntry, verifyDhtEntry } from "./dht-sign.ts";
58
58
  export type { DHTEntryData } from "./dht-sign.ts";
59
+
60
+ export { claimBytes, signClaim, verifyClaim } from "./claim-sign.ts";
61
+ export type { FleetClaim, FleetClaimData } from "./claim-sign.ts";
@@ -95,7 +95,7 @@ export interface WerkstattPlugin {
95
95
  schema: "werkstatt/plugin@1";
96
96
  /** Plugin id: "werkstatt-site" | "werkstatt-phaser-game" | "werkstatt-godot-game". */
97
97
  id: string;
98
- /** Forge stack profile id, e.g. "astro-typescript-turborepo". */
98
+ /** Forge stack profile id, e.g. "site". */
99
99
  profileId: string;
100
100
  /** Kernel modules the plugin contributes (validators, codegen, content, onboarding). */
101
101
  moduleLoaders: Record<string, () => Promise<KernelModule>>;
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  <MODULE_CONTRACT>
3
- <purpose>RFC-1075: derive persistent canonical URIs for PBP entity envelopes from site origin and entity identity. Used for Linked Data @id fields in JSON-LD and llms-full.txt projections.</purpose>
3
+ <purpose>RFC-1075: derive persistent canonical URIs for PBP entity envelopes from site origin and entity identity. Used for Linked Data @id fields in JSON-LD and llms-full.txt projections. RFC-1115: URIs carry a .json suffix and are dereferenceable — agent.knowledge.generate emits a static JSON-LD document at each path.</purpose>
4
4
  <non-goals>
5
5
  <item>Does not validate that the entity ID is well-formed — callers are responsible for providing valid IDs.</item>
6
6
  <item>Does not perform URL normalization beyond trailing-slash removal on the site origin.</item>
@@ -8,23 +8,26 @@
8
8
  </MODULE_CONTRACT>
9
9
  <CHANGE_SUMMARY>
10
10
  <item>RFC-1075: initial — deriveCanonicalUri pure function for persistent entity @id URIs.</item>
11
+ <item>RFC-1115: canonical entity URIs gain a .json suffix and become dereferenceable — agent.knowledge.generate emits a JSON-LD document at each emitted path.</item>
11
12
  </CHANGE_SUMMARY>
12
13
  */
13
14
 
14
15
  /**
15
16
  * Entity types supported by the PBP canonical URI scheme.
16
17
  *
17
- * The URI path pattern is `/.well-known/entity/{type}/{id}` for typed entities
18
- * (e.g. offerings) and `/.well-known/entity/business` for the single business entity.
18
+ * The URI path pattern is `/.well-known/entity/{type}/{id}.json` for typed entities
19
+ * (e.g. offerings) and `/.well-known/entity/business.json` for the single business entity.
20
+ * RFC-1115: the .json suffix makes every URI dereferenceable on a static host —
21
+ * agent.knowledge.generate emits a JSON-LD document at each path.
19
22
  */
20
23
  export type CanonicalEntityType = "business" | "offering";
21
24
 
22
25
  /**
23
26
  * RFC-1075: derive a persistent canonical URI for a PBP entity.
24
27
  *
25
- * The URI follows the pattern:
26
- * - Business: `{siteOrigin}/.well-known/entity/business`
27
- * - Offering: `{siteOrigin}/.well-known/entity/offering/{entityId}`
28
+ * The URI follows the pattern (RFC-1115 — dereferenceable .json form):
29
+ * - Business: `{siteOrigin}/.well-known/entity/business.json`
30
+ * - Offering: `{siteOrigin}/.well-known/entity/offering/{entityId}.json`
28
31
  *
29
32
  * The `siteOrigin` is stripped of trailing slashes to produce a clean base.
30
33
  * When `siteOrigin` is absent or empty, the function returns `undefined` —
@@ -35,6 +38,17 @@ export type CanonicalEntityType = "business" | "offering";
35
38
  * @param entityId - The entity ID (required for `"offering"`, ignored for `"business"`)
36
39
  * @returns The canonical URI string, or `undefined` when `siteOrigin` is absent
37
40
  */
41
+ /**
42
+ * Reduce a PBP entity id to the slug used in canonical entity URIs and file
43
+ * paths. Entity IDs are URIs (e.g. `https://warpgogol.com/id/offerings/automation`);
44
+ * the last non-empty path segment is the slug (`automation`). Plain slugs pass
45
+ * through unchanged. Single source for the id→slug mapping so the canonical
46
+ * `@id`, the emitted file path, and the fact-parity comparator stay consistent.
47
+ */
48
+ export function canonicalEntitySlug(entityId: string): string {
49
+ return entityId.split("/").filter(Boolean).pop() ?? entityId;
50
+ }
51
+
38
52
  export function deriveCanonicalUri(
39
53
  siteOrigin: string | undefined,
40
54
  type: CanonicalEntityType,
@@ -45,10 +59,16 @@ export function deriveCanonicalUri(
45
59
  const base = siteOrigin.replace(/\/+$/, "");
46
60
 
47
61
  if (type === "business") {
48
- return `${base}/.well-known/entity/business`;
62
+ return `${base}/.well-known/entity/business.json`;
49
63
  }
50
64
 
51
65
  if (!entityId) return undefined;
52
66
 
53
- return `${base}/.well-known/entity/offering/${entityId}`;
67
+ // Entity IDs are URIs (e.g. `https://warpgogol.com/id/offerings/automation`).
68
+ // Embedding the raw URI into the path produces a malformed double-scheme URI
69
+ // (`.../offering/https://warpgogol.com/...`) and a non-filesystem-safe path
70
+ // (`path.join` collapses `//` → `/`, breaking the @id = served-path parity).
71
+ const slug = canonicalEntitySlug(entityId);
72
+
73
+ return `${base}/.well-known/entity/offering/${slug}.json`;
54
74
  }
@@ -346,18 +346,26 @@ function formatMarkdownLinkRow(site: SemanticSiteModel, page: SemanticPageModel)
346
346
  return `- [${title}](${url}): ${summary}`;
347
347
  }
348
348
 
349
- export function buildLlmsIndex(site: SemanticSiteModel): string {
349
+ export function buildLlmsIndex(site: SemanticSiteModel, opts?: { servesSsr?: boolean }): string {
350
350
  const indexPages = site.pages.filter(inIndex);
351
351
  const llmsFullUrl = canonicalStaticUrl("/llms-full.txt", { baseUrl: site.baseUrl });
352
352
  const siteDescription = site.organization.description ?? "";
353
353
 
354
354
  // RFC-0789: agent discovery links — omitted when agent.enabled is false.
355
+ // The MCP Server Card exists only when the deployment adapter serves SSR
356
+ // routes (interfaces.mcp is set) — callers pass servesSsr=false on static
357
+ // adapters so the card is not advertised (PUBTXT-07 honesty).
355
358
  const agentEnabled = site.agent?.enabled !== false;
359
+ const servesSsr = opts?.servesSsr !== false;
356
360
  const agentLinks = agentEnabled
357
361
  ? [
358
362
  `> Machine-readable Agent Surface (structured knowledge + capabilities): [agent.json](${canonicalStaticUrl("/.well-known/agent.json", { baseUrl: site.baseUrl })}).`,
359
363
  `> API discovery catalog (RFC 9727): [api-catalog](${canonicalStaticUrl("/.well-known/api-catalog", { baseUrl: site.baseUrl })}).`,
360
- `> MCP Server Card (SEP-1649): [server-card.json](${canonicalStaticUrl("/.well-known/mcp/server-card.json", { baseUrl: site.baseUrl })}).`,
364
+ ...(servesSsr
365
+ ? [
366
+ `> MCP Server Card (SEP-1649): [server-card.json](${canonicalStaticUrl("/.well-known/mcp/server-card.json", { baseUrl: site.baseUrl })}).`,
367
+ ]
368
+ : []),
361
369
  `> OpenAPI 3.1 specification: [agent.openapi.json](${canonicalStaticUrl("/.well-known/agent.openapi.json", { baseUrl: site.baseUrl })}).`,
362
370
  ]
363
371
  : [];
@@ -12,18 +12,30 @@ describe("RFC-1075 AC-4: deriveCanonicalUri is pure and deterministic", () => {
12
12
  expect(deriveCanonicalUri("", "offering", "off-1")).toBeUndefined();
13
13
  });
14
14
 
15
- it("derives business URI without entityId", () => {
15
+ it("derives business URI without entityId — RFC-1115 .json form", () => {
16
16
  expect(deriveCanonicalUri("https://example.example", "business")).toBe(
17
- "https://example.example/.well-known/entity/business",
17
+ "https://example.example/.well-known/entity/business.json",
18
18
  );
19
19
  });
20
20
 
21
- it("derives offering URI with entityId", () => {
21
+ it("derives offering URI with entityId — RFC-1115 .json form", () => {
22
22
  expect(deriveCanonicalUri("https://example.example", "offering", "off-1")).toBe(
23
- "https://example.example/.well-known/entity/offering/off-1",
23
+ "https://example.example/.well-known/entity/offering/off-1.json",
24
24
  );
25
25
  });
26
26
 
27
+ it("slugifies URI-form entityId to its last path segment", () => {
28
+ // PBP entity IDs are URIs; the canonical entity URI must embed a slug,
29
+ // not the raw URI (which would produce a double-scheme, non-FS-safe path).
30
+ expect(
31
+ deriveCanonicalUri(
32
+ "https://example.example",
33
+ "offering",
34
+ "https://example.example/id/offerings/automation",
35
+ ),
36
+ ).toBe("https://example.example/.well-known/entity/offering/automation.json");
37
+ });
38
+
27
39
  it("returns undefined for offering without entityId", () => {
28
40
  expect(deriveCanonicalUri("https://example.example", "offering")).toBeUndefined();
29
41
  expect(deriveCanonicalUri("https://example.example", "offering", "")).toBeUndefined();
@@ -31,10 +43,10 @@ describe("RFC-1075 AC-4: deriveCanonicalUri is pure and deterministic", () => {
31
43
 
32
44
  it("strips trailing slashes from siteOrigin", () => {
33
45
  expect(deriveCanonicalUri("https://example.example/", "business")).toBe(
34
- "https://example.example/.well-known/entity/business",
46
+ "https://example.example/.well-known/entity/business.json",
35
47
  );
36
48
  expect(deriveCanonicalUri("https://example.example//", "business")).toBe(
37
- "https://example.example/.well-known/entity/business",
49
+ "https://example.example/.well-known/entity/business.json",
38
50
  );
39
51
  });
40
52
 
@@ -1,7 +1,9 @@
1
1
  /*
2
2
  <MODULE_CONTRACT>
3
3
  <purpose>Barrel export for @warpgogol/werkstatt-shared/signing sub-path. Re-exports the Ed25519 signing core (key generation, sign/verify, signing types) sunk from @warpgogol/werkstatt-engine per RFC-1104.</purpose>
4
- <non-goals>Does not re-export engine signing commands (signing-commands.ts stays in engine).</non-goals>
4
+ <non-goals>
5
+ <item>Does not re-export engine signing commands (signing-commands.ts stays in engine).</item>
6
+ </non-goals>
5
7
  </MODULE_CONTRACT>
6
8
  <CHANGE_SUMMARY>
7
9
  <item>RFC-1104: initial barrel — signing core sunk from werkstatt-engine.</item>
@@ -1,6 +1,6 @@
1
1
  /*
2
2
  <MODULE_CONTRACT>
3
- <purpose>runTool — the single subprocess seam for stack plugins (RFC-1100). Owns the
3
+ <purpose>runTool — the single subprocess seam that lets stack plugins run external binaries (RFC-1100). Owns the
4
4
  shared prologue every external-binary call needs: dist/ preflight, required-env
5
5
  presence check, env merge, timeout, and error mapping to ToolResult. Production
6
6
  uses the default execFileSync adapter; tests inject a recording ToolExecutor so no
@@ -91,9 +91,7 @@ export function runTool(spec: ToolSpec, executor: ToolExecutor = execFileExecuto
91
91
  if (missing.length > 0) {
92
92
  return {
93
93
  success: false,
94
- errors: missing.map(
95
- (name) => `Required environment variable ${name} is not set`,
96
- ),
94
+ errors: missing.map((name) => `Required environment variable ${name} is not set`),
97
95
  failedAt: "preflight",
98
96
  };
99
97
  }