From 2c20f8c5958fd19794f9758b020487d6b3bdac58 Mon Sep 17 00:00:00 2001 From: Andrei Hasna Date: Mon, 6 Jul 2026 18:20:01 +0300 Subject: [PATCH] feat: fleet comms wire schemas (event envelope, channel metadata, message metadata) v1 Registers three machine-validatable comms schemas per fleet comms workflow v1.1 (todos task 41ea5a6e, parent 843cfb62): - hasna.comms_event_envelope.v1: namespaced .. type, severities info/notice/breaking/critical, scope fleet/package/machine, affected_packages/affected_machines, action_required, ack_by, mandatory dedupe_key. fleet.freeze and fleet.unfreeze pinned critical + fleet-scoped + action-required. - hasna.comms_channel_metadata.v1: channel_schema class enum (fleet/package/product/loop-lane/initiative/personal), noise class, initiative owner + machine-evaluatable until horizon (date or gate:), archived-channel successor pointer. - hasna.comms_message_metadata.v1: severity-tag metadata for [FREEZE]/[UNFREEZE]/[BREAKING]/[CUTOVER]/[POLICY]/[RELEASE] exact-case first-token posts; envelope rides in --metadata, never parsed from text. Ships the one severity mapping table (COMMS_EVENT_TYPES / COMMS_SEVERITY_TAG_INFO with bijection tests), tag helpers (extractCommsSeverityTag, commsSeverityTagToken), and validateCommsTaggedMessage so hooks/loops/publishers validate before emit/post. Valid/invalid fixtures + conformance + unit tests; README catalog updated. @hasna/contracts 0.4.1 -> 0.5.0. --- README.md | 22 ++ examples/comms-channel-metadata.invalid.json | 7 + examples/comms-channel-metadata.valid.json | 9 + examples/comms-event-envelope.invalid.json | 14 + examples/comms-event-envelope.valid.json | 26 ++ examples/comms-message-metadata.invalid.json | 22 ++ examples/comms-message-metadata.valid.json | 27 ++ package.json | 2 +- src/schemas.ts | 348 +++++++++++++++++- tests/comms.test.ts | 364 +++++++++++++++++++ tests/examples.test.ts | 3 + 11 files changed, 840 insertions(+), 4 deletions(-) create mode 100644 examples/comms-channel-metadata.invalid.json create mode 100644 examples/comms-channel-metadata.valid.json create mode 100644 examples/comms-event-envelope.invalid.json create mode 100644 examples/comms-event-envelope.valid.json create mode 100644 examples/comms-message-metadata.invalid.json create mode 100644 examples/comms-message-metadata.valid.json create mode 100644 tests/comms.test.ts diff --git a/README.md b/README.md index 0051949..634a473 100644 --- a/README.md +++ b/README.md @@ -286,6 +286,28 @@ defaults are applied; output aliases such as `EvidenceRef` describe parsed data. tracked kit version, declared bins, and the `local | cloud` storage boundary. See `CONTRACT.md` for the normative spec and `contracts repo-conformance` / `runRepoConformance` for the self-check kit. +- `hasna.comms_event_envelope.v1`: fleet comms event envelope carried in + conversations message metadata — namespaced `..` type, + severity (`info | notice | breaking | critical`), scope + (`fleet | package | machine`), `affected_packages`/`affected_machines`, + `action_required`, `ack_by`, and a mandatory `dedupe_key`. + `fleet.freeze`/`fleet.unfreeze` are pinned critical + fleet-scoped + + action-required. The one severity mapping table ships as + `COMMS_EVENT_TYPES`/`COMMS_SEVERITY_TAG_INFO`. +- `hasna.comms_channel_metadata.v1`: the object stored under a conversations + channel's `metadata.channel_schema` key — channel `class` + (`fleet | package | product | loop-lane | initiative | personal`), noise class + (`quiet | work | firehose`), initiative `owner` + `until` horizon, and an + optional archived-channel `successor` pointer. +- `hasna.comms_message_metadata.v1`: structured metadata for severity-tagged + posts. The message text starts with `[FREEZE]`/`[UNFREEZE]`/`[BREAKING]`/ + `[CUTOVER]`/`[POLICY]`/`[RELEASE]` as its exact-case first token; the tag plus + the full event envelope ride in `--metadata`, never parsed from text. + Publishers, hooks, and loops validate with `validateCommsTaggedMessage` + (or `extractCommsSeverityTag` + `validateContract`) before emit/post. The + human-facing rules live in knowledge items `hasna-agent-comms-protocol` / + `hasna-agent-comms-envelope`; these schemas are the machine-validatable + source of truth. Every top-level contract includes a literal `schema` field. Consumers should reject objects whose embedded schema does not match the validator being used. diff --git a/examples/comms-channel-metadata.invalid.json b/examples/comms-channel-metadata.invalid.json new file mode 100644 index 0000000..c1c707e --- /dev/null +++ b/examples/comms-channel-metadata.invalid.json @@ -0,0 +1,7 @@ +{ + "schema": "hasna.comms_channel_metadata.v1", + "id": "research-shadow-fleet", + "createdAt": "2026-07-06T10:00:00.000Z", + "class": "initiative", + "noise": "work" +} diff --git a/examples/comms-channel-metadata.valid.json b/examples/comms-channel-metadata.valid.json new file mode 100644 index 0000000..7f9d89a --- /dev/null +++ b/examples/comms-channel-metadata.valid.json @@ -0,0 +1,9 @@ +{ + "schema": "hasna.comms_channel_metadata.v1", + "id": "oss-cloud-runtime", + "createdAt": "2026-07-06T10:00:00.000Z", + "class": "initiative", + "noise": "work", + "owner": "chief", + "until": "gate:97610c99" +} diff --git a/examples/comms-event-envelope.invalid.json b/examples/comms-event-envelope.invalid.json new file mode 100644 index 0000000..d65fc89 --- /dev/null +++ b/examples/comms-event-envelope.invalid.json @@ -0,0 +1,14 @@ +{ + "schema": "hasna.comms_event_envelope.v1", + "id": "evt_release_breaking_bad_scope", + "createdAt": "2026-07-06T10:00:00.000Z", + "type": "release.breaking", + "severity": "breaking", + "scope": "package", + "summary": "Package-scoped breaking event without target packages and with an ack deadline but no action_required", + "affected_packages": [], + "affected_machines": [], + "action_required": false, + "ack_by": "2026-07-07T10:00:00.000Z", + "dedupe_key": "release.breaking:@hasna/loops@0.4.0" +} diff --git a/examples/comms-event-envelope.valid.json b/examples/comms-event-envelope.valid.json new file mode 100644 index 0000000..fec40d2 --- /dev/null +++ b/examples/comms-event-envelope.valid.json @@ -0,0 +1,26 @@ +{ + "schema": "hasna.comms_event_envelope.v1", + "id": "evt_fleet_freeze_20260706", + "createdAt": "2026-07-06T10:00:00.000Z", + "type": "fleet.freeze", + "severity": "critical", + "scope": "fleet", + "summary": "Freeze publish, deploy, and shared config apply during the conversations storage cutover", + "source": { + "kind": "agent", + "id": "chief", + "name": "chief" + }, + "affected_packages": [], + "affected_machines": ["spark01", "spark02", "apple01", "apple03", "apple06"], + "action_required": true, + "ack_by": "2026-07-06T12:00:00.000Z", + "dedupe_key": "fleet.freeze:2026-07-06:conversations-storage-cutover", + "resourceRefs": [ + { + "kind": "incident", + "id": "inc_conversations_cutover_20260706" + } + ], + "evidenceRefs": [] +} diff --git a/examples/comms-message-metadata.invalid.json b/examples/comms-message-metadata.invalid.json new file mode 100644 index 0000000..1b6507b --- /dev/null +++ b/examples/comms-message-metadata.invalid.json @@ -0,0 +1,22 @@ +{ + "schema": "hasna.comms_message_metadata.v1", + "id": "msg_release_tag_on_freeze_event", + "createdAt": "2026-07-06T10:00:05.000Z", + "tag": "RELEASE", + "envelope": { + "schema": "hasna.comms_event_envelope.v1", + "id": "evt_fleet_freeze_20260706", + "createdAt": "2026-07-06T10:00:00.000Z", + "type": "fleet.freeze", + "severity": "critical", + "scope": "fleet", + "summary": "A fleet.freeze event mislabeled as a release post", + "affected_packages": [], + "affected_machines": [], + "action_required": true, + "ack_by": "2026-07-06T12:00:00.000Z", + "dedupe_key": "fleet.freeze:2026-07-06:conversations-storage-cutover", + "resourceRefs": [], + "evidenceRefs": [] + } +} diff --git a/examples/comms-message-metadata.valid.json b/examples/comms-message-metadata.valid.json new file mode 100644 index 0000000..97f7702 --- /dev/null +++ b/examples/comms-message-metadata.valid.json @@ -0,0 +1,27 @@ +{ + "schema": "hasna.comms_message_metadata.v1", + "id": "msg_announcements_freeze_20260706", + "createdAt": "2026-07-06T10:00:05.000Z", + "tag": "FREEZE", + "envelope": { + "schema": "hasna.comms_event_envelope.v1", + "id": "evt_fleet_freeze_20260706", + "createdAt": "2026-07-06T10:00:00.000Z", + "type": "fleet.freeze", + "severity": "critical", + "scope": "fleet", + "summary": "Freeze publish, deploy, and shared config apply during the conversations storage cutover", + "source": { + "kind": "agent", + "id": "chief", + "name": "chief" + }, + "affected_packages": [], + "affected_machines": ["spark01", "spark02", "apple01", "apple03", "apple06"], + "action_required": true, + "ack_by": "2026-07-06T12:00:00.000Z", + "dedupe_key": "fleet.freeze:2026-07-06:conversations-storage-cutover", + "resourceRefs": [], + "evidenceRefs": [] + } +} diff --git a/package.json b/package.json index 975883c..6079f7d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hasna/contracts", - "version": "0.4.1", + "version": "0.5.0", "description": "Shared schemas and validators for Hasna open-source agent infrastructure contracts.", "type": "module", "license": "Apache-2.0", diff --git a/src/schemas.ts b/src/schemas.ts index 11a21ac..86927da 100644 --- a/src/schemas.ts +++ b/src/schemas.ts @@ -1,7 +1,7 @@ import { z } from "zod"; export const CONTRACTS_PACKAGE_NAME = "@hasna/contracts"; -export const CONTRACTS_PACKAGE_VERSION = "0.4.1"; +export const CONTRACTS_PACKAGE_VERSION = "0.5.0"; export const SCHEMA_IDS = { actorRef: "hasna.actor_ref.v1", @@ -24,7 +24,10 @@ export const SCHEMA_IDS = { scaffoldInstallRecord: "hasna.scaffold_install_record.v1", appCloudManifest: "hasna.app_cloud_manifest.v1", noCloudEvidencePack: "hasna.no_cloud_evidence_pack.v1", - serviceContract: "hasna.service_contract.v1" + serviceContract: "hasna.service_contract.v1", + commsEventEnvelope: "hasna.comms_event_envelope.v1", + commsChannelMetadata: "hasna.comms_channel_metadata.v1", + commsMessageMetadata: "hasna.comms_message_metadata.v1" } as const; export const SchemaIdSchema = z @@ -1710,6 +1713,333 @@ export const VersionResponseSchema = z .strict(); export type VersionResponse = z.infer; +// --------------------------------------------------------------------------- +// Fleet comms wire schemas (Hasna fleet comms workflow v1.1) +// +// Machine-validatable wire shapes for the fleet communication protocol: +// 1. `hasna.comms_event_envelope.v1` — the event envelope carried in +// conversations message `--metadata` (never parsed from message text). +// 2. `hasna.comms_channel_metadata.v1` — the object stored under a +// conversations channel's `metadata.channel_schema` key. +// 3. `hasna.comms_message_metadata.v1` — structured metadata for +// severity-tagged posts ([FREEZE]/[UNFREEZE]/[BREAKING]/[CUTOVER]/ +// [POLICY]/[RELEASE] exact-case first token). +// +// Naming: the comms-specific wire fields are snake_case (`affected_packages`, +// `affected_machines`, `action_required`, `ack_by`, `dedupe_key`) because they +// are the canonical keys deterministic sweepers and hooks read from message +// metadata with jq; the shared contract envelope fields (`schema`, `id`, +// `createdAt`) keep the registry-wide camelCase convention. +// +// The human-facing rules live in knowledge item `hasna-agent-comms-protocol`; +// the severity mapping documentation lives in `hasna-agent-comms-envelope`, +// which cross-references these schema ids as the machine-validatable source +// of truth. +// --------------------------------------------------------------------------- + +/** Fleet comms severity ladder (one severity system fleet-wide). */ +export const CommsSeveritySchema = z.enum(["info", "notice", "breaking", "critical"]); +export type CommsSeverity = z.infer; + +/** + * Namespaced comms event type: `..` style, 2-4 + * lowercase dot-separated segments (e.g. `fleet.freeze`, `release.published`, + * `comms.protocol.bumped`, `cloud.cutover.step`). + */ +export const CommsEventTypeSchema = z + .string() + .regex( + /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*){1,3}$/, + "Comms event types must be 2-4 lowercase dot-separated segments (..)" + ); +export type CommsEventType = z.infer; + +/** Severity tags allowed as the exact-case first token of tagged posts. */ +export const COMMS_SEVERITY_TAGS = ["FREEZE", "UNFREEZE", "BREAKING", "CUTOVER", "POLICY", "RELEASE"] as const; +export const CommsSeverityTagSchema = z.enum(COMMS_SEVERITY_TAGS); +export type CommsSeverityTag = z.infer; + +/** + * The one channel-tag <-> severity <-> event-type mapping table for the known + * fleet event types. `defaultSeverity` is the publisher default; only + * `fleet.freeze`/`fleet.unfreeze` pin severity hard (always critical). + * `tag` is the announcements severity tag a post for this event type carries + * (null = the event posts without a severity tag, e.g. to incidents). + */ +export const COMMS_EVENT_TYPES: Readonly< + Record +> = { + "release.published": { defaultSeverity: "info", tag: "RELEASE" }, + "release.breaking": { defaultSeverity: "breaking", tag: "BREAKING" }, + "config.changed": { defaultSeverity: "notice", tag: null }, + "comms.protocol.bumped": { defaultSeverity: "breaking", tag: "POLICY" }, + "incident.opened": { defaultSeverity: "critical", tag: null }, + "incident.resolved": { defaultSeverity: "notice", tag: null }, + "cloud.cutover.step": { defaultSeverity: "notice", tag: "CUTOVER" }, + "fleet.freeze": { defaultSeverity: "critical", tag: "FREEZE" }, + "fleet.unfreeze": { defaultSeverity: "critical", tag: "UNFREEZE" }, + "fleet.directive": { defaultSeverity: "notice", tag: null } +} as const; + +/** Default severity for a known comms event type, null when unregistered. */ +export function defaultSeverityForCommsEventType(type: string): CommsSeverity | null { + return COMMS_EVENT_TYPES[type]?.defaultSeverity ?? null; +} + +/** Blast-radius scope of a comms event. */ +export const CommsScopeSchema = z.enum(["fleet", "package", "machine"]); +export type CommsScope = z.infer; + +export const CommsEventEnvelopeSchema = contractBaseSchema(SCHEMA_IDS.commsEventEnvelope) + .extend({ + type: CommsEventTypeSchema, + severity: CommsSeveritySchema, + scope: CommsScopeSchema, + summary: z.string().min(1).optional(), + source: ActorPointerSchema.optional(), + affected_packages: z.array(NonEmptyStringSchema).default([]), + affected_machines: z.array(NonEmptyStringSchema).default([]), + action_required: z.boolean().default(false), + ack_by: TimestampSchema.optional(), + dedupe_key: NonEmptyStringSchema, + resourceRefs: z.array(ResourcePointerSchema).default([]), + evidenceRefs: z.array(EvidencePointerSchema).default([]) + }) + .strict() + .superRefine((value, ctx) => { + if (value.scope === "package" && value.affected_packages.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Package-scoped comms events require affected_packages", + path: ["affected_packages"] + }); + } + if (value.scope === "machine" && value.affected_machines.length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Machine-scoped comms events require affected_machines", + path: ["affected_machines"] + }); + } + if (value.ack_by && !value.action_required) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Comms events with an ack_by deadline require action_required", + path: ["action_required"] + }); + } + if (value.type === "fleet.freeze" || value.type === "fleet.unfreeze") { + if (value.severity !== "critical") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.type} events are always critical`, + path: ["severity"] + }); + } + if (value.scope !== "fleet") { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.type} events are always fleet-scoped`, + path: ["scope"] + }); + } + if (!value.action_required) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.type} events require action_required`, + path: ["action_required"] + }); + } + } + }); +export type CommsEventEnvelope = z.infer; + +/** Channel classes from the fleet channel taxonomy. */ +export const CommsChannelClassSchema = z.enum(["fleet", "package", "product", "loop-lane", "initiative", "personal"]); +export type CommsChannelClass = z.infer; + +/** Noise classes: quiet (push-eligible) / work (digest-read) / firehose (never pushed). */ +export const CommsChannelNoiseSchema = z.enum(["quiet", "work", "firehose"]); +export type CommsChannelNoise = z.infer; + +/** + * Machine-evaluatable channel horizon: an ISO date (`2026-08-01`), a UTC + * timestamp, or a gate id (`gate:97610c99` — a todos task short id or uuid). + * Free-form horizons ("soon") defeat the channel-hygiene loop, so they are + * rejected at the wire. + */ +export const CommsUntilHorizonSchema = NonEmptyStringSchema.refine( + (value) => /^(?:\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z)?|gate:[0-9a-f][0-9a-f-]{7,35})$/.test(value), + "until must be an ISO date (YYYY-MM-DD), a UTC timestamp, or a gate id (gate:)" +); +export type CommsUntilHorizon = z.infer; + +/** + * The object stored under a conversations channel's `metadata.channel_schema` + * key. `id` is the channel name. Initiative channels must carry an owner and + * an `until` horizon (an ISO date or a gate id such as `gate:97610c99`); + * archived channels point at their successor channel. + */ +export const CommsChannelMetadataSchema = contractBaseSchema(SCHEMA_IDS.commsChannelMetadata) + .extend({ + class: CommsChannelClassSchema, + noise: CommsChannelNoiseSchema.optional(), + owner: NonEmptyStringSchema.optional(), + until: CommsUntilHorizonSchema.optional(), + successor: NonEmptyStringSchema.optional() + }) + .strict() + .superRefine((value, ctx) => { + if (value.class === "initiative") { + if (!value.owner) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Initiative channels require an owner", + path: ["owner"] + }); + } + if (!value.until) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Initiative channels require an until horizon (date or gate id)", + path: ["until"] + }); + } + } + }); +export type CommsChannelMetadata = z.infer; + +/** + * Per-tag severity constraints. `defaultSeverity` mirrors the mapping table; + * `allowedSeverities` bounds what a tagged post may carry (`cloud.cutover.step` + * is notice by default and breaking only for machines actually being cut). + * `requiredEventType` pins tags that map to exactly one event type. + */ +export const COMMS_SEVERITY_TAG_INFO: Readonly< + Record< + CommsSeverityTag, + { + readonly defaultSeverity: CommsSeverity; + readonly allowedSeverities: readonly CommsSeverity[]; + readonly requiredEventType: CommsEventType | null; + } + > +> = { + FREEZE: { defaultSeverity: "critical", allowedSeverities: ["critical"], requiredEventType: "fleet.freeze" }, + UNFREEZE: { defaultSeverity: "critical", allowedSeverities: ["critical"], requiredEventType: "fleet.unfreeze" }, + BREAKING: { defaultSeverity: "breaking", allowedSeverities: ["breaking"], requiredEventType: null }, + CUTOVER: { defaultSeverity: "notice", allowedSeverities: ["notice", "breaking"], requiredEventType: null }, + POLICY: { defaultSeverity: "breaking", allowedSeverities: ["notice", "breaking"], requiredEventType: null }, + RELEASE: { defaultSeverity: "info", allowedSeverities: ["info", "notice"], requiredEventType: null } +} as const; + +/** + * Structured metadata a severity-tagged post carries in `--metadata`. The + * message text must start with `[]` as its exact-case first token; the + * event envelope rides inside the metadata, never parsed from text. + */ +export const CommsMessageMetadataSchema = contractBaseSchema(SCHEMA_IDS.commsMessageMetadata) + .extend({ + tag: CommsSeverityTagSchema, + envelope: CommsEventEnvelopeSchema + }) + .strict() + .superRefine((value, ctx) => { + const info = COMMS_SEVERITY_TAG_INFO[value.tag]; + if (!info.allowedSeverities.includes(value.envelope.severity)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `[${value.tag}] posts allow severities ${info.allowedSeverities.join(", ")}`, + path: ["envelope", "severity"] + }); + } + if (info.requiredEventType && value.envelope.type !== info.requiredEventType) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `[${value.tag}] posts require event type ${info.requiredEventType}`, + path: ["envelope", "type"] + }); + } + for (const [tag, tagInfo] of Object.entries(COMMS_SEVERITY_TAG_INFO)) { + if (tagInfo.requiredEventType === value.envelope.type && value.tag !== tag) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `${value.envelope.type} events must use the [${tag}] tag`, + path: ["tag"] + }); + } + } + }); +export type CommsMessageMetadata = z.infer; + +/** Renders the exact-case first token for a severity tag, e.g. `[FREEZE]`. */ +export function commsSeverityTagToken(tag: CommsSeverityTag): string { + return `[${tag}]`; +} + +/** + * Extracts the severity tag from a message text's first token. Exact case + * only: `[FREEZE]` matches, `[freeze]`/`[Freeze]` and mid-text tags do not. + * Leading whitespace is tolerated (tokenizers like `awk '{print $1}'` skip it). + */ +export function extractCommsSeverityTag(text: string): CommsSeverityTag | null { + const firstToken = text.trimStart().split(/\s+/, 1)[0] ?? ""; + for (const tag of COMMS_SEVERITY_TAGS) { + if (firstToken === `[${tag}]`) { + return tag; + } + } + return null; +} + +export type CommsTaggedMessageValidationResult = + | { success: true; tag: CommsSeverityTag; metadata: CommsMessageMetadata } + | { success: false; tag: CommsSeverityTag | null; issues: z.ZodIssue[] }; + +/** + * Validates a severity-tagged conversations post before it is sent: the text + * must start with a known exact-case tag token, the structured metadata must + * parse as `hasna.comms_message_metadata.v1`, and the metadata tag must match + * the text token. Publishers, hooks, and loops call this before emit/post. + */ +export function validateCommsTaggedMessage(input: { + text: string; + metadata: unknown; +}): CommsTaggedMessageValidationResult { + const tag = extractCommsSeverityTag(input.text); + if (!tag) { + return { + success: false, + tag: null, + issues: [ + { + code: z.ZodIssueCode.custom, + message: `Severity-tagged posts must start with one of ${COMMS_SEVERITY_TAGS.map(commsSeverityTagToken).join(" ")} as the exact-case first token`, + path: ["text"] + } + ] + }; + } + const parsed = CommsMessageMetadataSchema.safeParse(input.metadata); + if (!parsed.success) { + return { success: false, tag, issues: parsed.error.issues }; + } + if (parsed.data.tag !== tag) { + return { + success: false, + tag, + issues: [ + { + code: z.ZodIssueCode.custom, + message: `Message text is tagged [${tag}] but metadata declares [${parsed.data.tag}]`, + path: ["tag"] + } + ] + }; + } + return { success: true, tag, metadata: parsed.data }; +} + export const ContractSchemaRegistry = { [SCHEMA_IDS.actorRef]: ActorRefSchema, [SCHEMA_IDS.resourceRef]: ResourceRefSchema, @@ -1731,7 +2061,10 @@ export const ContractSchemaRegistry = { [SCHEMA_IDS.scaffoldInstallRecord]: ScaffoldInstallRecordSchema, [SCHEMA_IDS.appCloudManifest]: AppCloudManifestSchema, [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePackSchema, - [SCHEMA_IDS.serviceContract]: ServiceContractManifestSchema + [SCHEMA_IDS.serviceContract]: ServiceContractManifestSchema, + [SCHEMA_IDS.commsEventEnvelope]: CommsEventEnvelopeSchema, + [SCHEMA_IDS.commsChannelMetadata]: CommsChannelMetadataSchema, + [SCHEMA_IDS.commsMessageMetadata]: CommsMessageMetadataSchema } as const; export type KnownSchemaId = keyof typeof ContractSchemaRegistry; @@ -1758,6 +2091,9 @@ export type ContractBySchemaId = { [SCHEMA_IDS.appCloudManifest]: AppCloudManifest; [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePack; [SCHEMA_IDS.serviceContract]: ServiceContractManifest; + [SCHEMA_IDS.commsEventEnvelope]: CommsEventEnvelope; + [SCHEMA_IDS.commsChannelMetadata]: CommsChannelMetadata; + [SCHEMA_IDS.commsMessageMetadata]: CommsMessageMetadata; }; export type ActorRefInput = z.input; @@ -1781,6 +2117,9 @@ export type ScaffoldInstallRecordInput = z.input; export type NoCloudEvidencePackInput = z.input; export type ServiceContractManifestInput = z.input; +export type CommsEventEnvelopeInput = z.input; +export type CommsChannelMetadataInput = z.input; +export type CommsMessageMetadataInput = z.input; export type ActorPointerInput = z.input; export type ResourcePointerInput = z.input; export type EvidencePointerInput = z.input; @@ -1807,4 +2146,7 @@ export type ContractInputBySchemaId = { [SCHEMA_IDS.appCloudManifest]: AppCloudManifestInput; [SCHEMA_IDS.noCloudEvidencePack]: NoCloudEvidencePackInput; [SCHEMA_IDS.serviceContract]: ServiceContractManifestInput; + [SCHEMA_IDS.commsEventEnvelope]: CommsEventEnvelopeInput; + [SCHEMA_IDS.commsChannelMetadata]: CommsChannelMetadataInput; + [SCHEMA_IDS.commsMessageMetadata]: CommsMessageMetadataInput; }; diff --git a/tests/comms.test.ts b/tests/comms.test.ts new file mode 100644 index 0000000..daa3aac --- /dev/null +++ b/tests/comms.test.ts @@ -0,0 +1,364 @@ +import { describe, expect, test } from "bun:test"; +import { + COMMS_EVENT_TYPES, + COMMS_SEVERITY_TAG_INFO, + COMMS_SEVERITY_TAGS, + CommsChannelMetadataSchema, + CommsEventEnvelopeSchema, + CommsEventTypeSchema, + CommsMessageMetadataSchema, + commsSeverityTagToken, + defaultSeverityForCommsEventType, + extractCommsSeverityTag, + SCHEMA_IDS, + validateCommsTaggedMessage, + validateContract, + validateEmbeddedContract +} from "../src"; + +const createdAt = "2026-07-06T10:00:00.000Z"; + +function envelope(overrides: Record = {}) { + return { + schema: SCHEMA_IDS.commsEventEnvelope, + id: "evt_test_1", + createdAt, + type: "release.published", + severity: "info", + scope: "fleet", + dedupe_key: "release.published:@hasna/contracts@0.5.0", + ...overrides + }; +} + +function channelMetadata(overrides: Record = {}) { + return { + schema: SCHEMA_IDS.commsChannelMetadata, + id: "announcements", + createdAt, + class: "fleet", + ...overrides + }; +} + +function freezeEnvelope(overrides: Record = {}) { + return envelope({ + id: "evt_fleet_freeze_1", + type: "fleet.freeze", + severity: "critical", + scope: "fleet", + action_required: true, + ack_by: "2026-07-06T12:00:00.000Z", + dedupe_key: "fleet.freeze:2026-07-06:test", + ...overrides + }); +} + +function messageMetadata(overrides: Record = {}) { + return { + schema: SCHEMA_IDS.commsMessageMetadata, + id: "msg_test_1", + createdAt, + tag: "FREEZE", + envelope: freezeEnvelope(), + ...overrides + }; +} + +type AnySafeParseResult = + | { success: true } + | { success: false; error: { issues: { path: (string | number)[] }[] } }; + +function issuePaths(result: AnySafeParseResult): string[] { + return result.success ? [] : result.error.issues.map((issue) => issue.path.join(".")).sort(); +} + +describe("comms event envelope", () => { + test("parses a minimal fleet-scoped event and applies defaults", () => { + const parsed = CommsEventEnvelopeSchema.parse(envelope()); + expect(parsed.affected_packages).toEqual([]); + expect(parsed.affected_machines).toEqual([]); + expect(parsed.action_required).toBe(false); + expect(parsed.resourceRefs).toEqual([]); + expect(parsed.evidenceRefs).toEqual([]); + }); + + test("dedupe_key is mandatory and non-empty", () => { + const { dedupe_key: _dropped, ...withoutKey } = envelope(); + expect(CommsEventEnvelopeSchema.safeParse(withoutKey).success).toBe(false); + expect(CommsEventEnvelopeSchema.safeParse(envelope({ dedupe_key: " " })).success).toBe(false); + }); + + test("rejects unknown keys (strict wire shape)", () => { + expect(CommsEventEnvelopeSchema.safeParse(envelope({ urgent: true })).success).toBe(false); + }); + + test("package scope requires affected_packages", () => { + const bad = CommsEventEnvelopeSchema.safeParse(envelope({ scope: "package" })); + expect(issuePaths(bad)).toEqual(["affected_packages"]); + const good = CommsEventEnvelopeSchema.safeParse( + envelope({ scope: "package", affected_packages: ["@hasna/loops"] }) + ); + expect(good.success).toBe(true); + }); + + test("machine scope requires affected_machines", () => { + const bad = CommsEventEnvelopeSchema.safeParse(envelope({ scope: "machine" })); + expect(issuePaths(bad)).toEqual(["affected_machines"]); + const good = CommsEventEnvelopeSchema.safeParse(envelope({ scope: "machine", affected_machines: ["spark01"] })); + expect(good.success).toBe(true); + }); + + test("ack_by requires action_required", () => { + const bad = CommsEventEnvelopeSchema.safeParse(envelope({ ack_by: "2026-07-07T10:00:00.000Z" })); + expect(issuePaths(bad)).toEqual(["action_required"]); + const good = CommsEventEnvelopeSchema.safeParse( + envelope({ ack_by: "2026-07-07T10:00:00.000Z", action_required: true }) + ); + expect(good.success).toBe(true); + }); + + test("fleet.freeze and fleet.unfreeze pin critical + fleet scope + action_required", () => { + expect(CommsEventEnvelopeSchema.safeParse(freezeEnvelope()).success).toBe(true); + expect( + CommsEventEnvelopeSchema.safeParse(freezeEnvelope({ type: "fleet.unfreeze", dedupe_key: "fleet.unfreeze:test" })) + .success + ).toBe(true); + + const wrong = CommsEventEnvelopeSchema.safeParse( + freezeEnvelope({ severity: "notice", scope: "machine", affected_machines: ["spark01"], action_required: false, ack_by: undefined }) + ); + expect(issuePaths(wrong)).toEqual(["action_required", "scope", "severity"]); + }); + + test("event types must be 2-4 lowercase dot-separated segments", () => { + expect(CommsEventTypeSchema.safeParse("fleet.freeze").success).toBe(true); + expect(CommsEventTypeSchema.safeParse("comms.protocol.bumped").success).toBe(true); + expect(CommsEventTypeSchema.safeParse("a.b.c.d").success).toBe(true); + expect(CommsEventTypeSchema.safeParse("freeze").success).toBe(false); + expect(CommsEventTypeSchema.safeParse("a.b.c.d.e").success).toBe(false); + expect(CommsEventTypeSchema.safeParse("Fleet.Freeze").success).toBe(false); + expect(CommsEventTypeSchema.safeParse("fleet..freeze").success).toBe(false); + expect(CommsEventTypeSchema.safeParse("fleet.freeze ").success).toBe(false); + }); +}); + +describe("comms channel metadata", () => { + test("accepts every channel class from the taxonomy", () => { + for (const cls of ["fleet", "package", "product", "loop-lane", "initiative", "personal"]) { + const value = channelMetadata( + cls === "initiative" ? { class: cls, owner: "chief", until: "2026-08-01" } : { class: cls } + ); + expect(CommsChannelMetadataSchema.safeParse(value).success).toBe(true); + } + expect(CommsChannelMetadataSchema.safeParse(channelMetadata({ class: "machine" })).success).toBe(false); + }); + + test("initiative channels require owner and until", () => { + const bad = CommsChannelMetadataSchema.safeParse(channelMetadata({ class: "initiative" })); + expect(issuePaths(bad)).toEqual(["owner", "until"]); + const gateBound = CommsChannelMetadataSchema.safeParse( + channelMetadata({ class: "initiative", owner: "chief", until: "gate:97610c99" }) + ); + expect(gateBound.success).toBe(true); + }); + + test("archived channels can carry a successor pointer, unknown keys rejected", () => { + expect(CommsChannelMetadataSchema.safeParse(channelMetadata({ successor: "ops" })).success).toBe(true); + expect(CommsChannelMetadataSchema.safeParse(channelMetadata({ members: ["chief"] })).success).toBe(false); + }); + + test("noise classes are quiet/work/firehose", () => { + for (const noise of ["quiet", "work", "firehose"]) { + expect(CommsChannelMetadataSchema.safeParse(channelMetadata({ noise })).success).toBe(true); + } + expect(CommsChannelMetadataSchema.safeParse(channelMetadata({ noise: "loud" })).success).toBe(false); + }); + + test("until horizons must be machine-evaluatable (date, timestamp, or gate id)", () => { + const initiative = (until: string) => channelMetadata({ class: "initiative", owner: "chief", until }); + expect(CommsChannelMetadataSchema.safeParse(initiative("2026-08-01")).success).toBe(true); + expect(CommsChannelMetadataSchema.safeParse(initiative("2026-08-01T12:00:00Z")).success).toBe(true); + expect(CommsChannelMetadataSchema.safeParse(initiative("gate:97610c99")).success).toBe(true); + expect( + CommsChannelMetadataSchema.safeParse(initiative("gate:97610c99-aaaa-bbbb-cccc-dddddddddddd")).success + ).toBe(true); + for (const bad of ["soon", "next quarter", "gate:", "gate:xyz", "08/01/2026"]) { + const result = CommsChannelMetadataSchema.safeParse(initiative(bad)); + expect(result.success).toBe(false); + expect(issuePaths(result)).toEqual(["until"]); + } + }); +}); + +describe("comms message metadata", () => { + test("accepts a matching tag + envelope", () => { + expect(CommsMessageMetadataSchema.safeParse(messageMetadata()).success).toBe(true); + }); + + test("bounds severity per tag", () => { + const bad = CommsMessageMetadataSchema.safeParse( + messageMetadata({ + tag: "RELEASE", + envelope: envelope({ severity: "breaking" }) + }) + ); + expect(bad.success).toBe(false); + if (!bad.success) { + expect(bad.error.issues.map((issue) => issue.path.join("."))).toContain("envelope.severity"); + } + }); + + test("FREEZE/UNFREEZE tags pin their event type both directions", () => { + const wrongType = CommsMessageMetadataSchema.safeParse( + messageMetadata({ tag: "FREEZE", envelope: envelope({ severity: "critical" }) }) + ); + expect(wrongType.success).toBe(false); + if (!wrongType.success) { + expect(wrongType.error.issues.map((issue) => issue.path.join("."))).toContain("envelope.type"); + } + + const wrongTag = CommsMessageMetadataSchema.safeParse(messageMetadata({ tag: "CUTOVER" })); + expect(wrongTag.success).toBe(false); + if (!wrongTag.success) { + expect(wrongTag.error.issues.map((issue) => issue.path.join("."))).toContain("tag"); + } + }); +}); + +describe("severity mapping table", () => { + test("covers the strategy's event types with the ruled severities", () => { + expect(defaultSeverityForCommsEventType("release.published")).toBe("info"); + expect(defaultSeverityForCommsEventType("release.breaking")).toBe("breaking"); + expect(defaultSeverityForCommsEventType("config.changed")).toBe("notice"); + expect(defaultSeverityForCommsEventType("comms.protocol.bumped")).toBe("breaking"); + expect(defaultSeverityForCommsEventType("incident.opened")).toBe("critical"); + expect(defaultSeverityForCommsEventType("incident.resolved")).toBe("notice"); + expect(defaultSeverityForCommsEventType("cloud.cutover.step")).toBe("notice"); + expect(defaultSeverityForCommsEventType("fleet.freeze")).toBe("critical"); + expect(defaultSeverityForCommsEventType("fleet.unfreeze")).toBe("critical"); + expect(defaultSeverityForCommsEventType("fleet.directive")).toBe("notice"); + expect(defaultSeverityForCommsEventType("made.up.type")).toBeNull(); + }); + + test("every mapped event type is a valid namespaced type and tag defaults stay in bounds", () => { + for (const [type, info] of Object.entries(COMMS_EVENT_TYPES)) { + expect(CommsEventTypeSchema.safeParse(type).success).toBe(true); + if (info.tag) { + const tagInfo = COMMS_SEVERITY_TAG_INFO[info.tag]; + expect(tagInfo.allowedSeverities).toContain(info.defaultSeverity); + } + } + }); + + test("tag table stays aligned with the tag enum", () => { + expect(Object.keys(COMMS_SEVERITY_TAG_INFO).sort()).toEqual([...COMMS_SEVERITY_TAGS].sort()); + for (const info of Object.values(COMMS_SEVERITY_TAG_INFO)) { + expect(info.allowedSeverities).toContain(info.defaultSeverity); + } + }); + + test("tag <-> event-type pins are a bijection across both tables", () => { + // Every tag that pins an event type must be that event type's tag... + for (const [tag, info] of Object.entries(COMMS_SEVERITY_TAG_INFO)) { + if (info.requiredEventType) { + expect(COMMS_EVENT_TYPES[info.requiredEventType]?.tag).toBe(tag as (typeof COMMS_SEVERITY_TAGS)[number]); + } + } + // ...and every event type whose tag pins a type must pin back to itself. + for (const [type, info] of Object.entries(COMMS_EVENT_TYPES)) { + if (info.tag) { + const pinned = COMMS_SEVERITY_TAG_INFO[info.tag].requiredEventType; + if (pinned) { + expect(pinned).toBe(type); + } + } + } + // The envelope-level hard pins cover exactly the tag-table pins. + const pinnedTypes = Object.values(COMMS_SEVERITY_TAG_INFO) + .map((info) => info.requiredEventType) + .filter((type): type is string => type !== null) + .sort(); + expect(pinnedTypes).toEqual(["fleet.freeze", "fleet.unfreeze"]); + }); +}); + +describe("severity tag extraction", () => { + test("extracts exact-case first-token tags only", () => { + expect(extractCommsSeverityTag("[FREEZE] halt publishes")).toBe("FREEZE"); + expect(extractCommsSeverityTag(" [UNFREEZE] resume")).toBe("UNFREEZE"); + expect(extractCommsSeverityTag("[BREAKING]\nloops 0.4 drops --once")).toBe("BREAKING"); + expect(extractCommsSeverityTag("[RELEASE]")).toBe("RELEASE"); + expect(extractCommsSeverityTag("[freeze] lowercase")).toBeNull(); + expect(extractCommsSeverityTag("[Freeze] mixed case")).toBeNull(); + expect(extractCommsSeverityTag("heads up [FREEZE] mid-text")).toBeNull(); + expect(extractCommsSeverityTag("[FREEZE]: colon glued")).toBeNull(); + expect(extractCommsSeverityTag("FREEZE no brackets")).toBeNull(); + expect(extractCommsSeverityTag("")).toBeNull(); + }); + + test("renders tag tokens", () => { + expect(commsSeverityTagToken("FREEZE")).toBe("[FREEZE]"); + }); +}); + +describe("validateCommsTaggedMessage", () => { + test("accepts a tagged post whose metadata matches", () => { + const result = validateCommsTaggedMessage({ + text: "[FREEZE] halt publish/deploy during conversations cutover", + metadata: messageMetadata() + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.tag).toBe("FREEZE"); + expect(result.metadata.envelope.type).toBe("fleet.freeze"); + } + }); + + test("rejects untagged or mis-tagged text", () => { + const untagged = validateCommsTaggedMessage({ text: "please freeze", metadata: messageMetadata() }); + expect(untagged.success).toBe(false); + if (!untagged.success) { + expect(untagged.tag).toBeNull(); + expect(untagged.issues[0]?.path).toEqual(["text"]); + } + }); + + test("rejects metadata that fails the schema", () => { + const result = validateCommsTaggedMessage({ + text: "[FREEZE] halt", + metadata: messageMetadata({ envelope: freezeEnvelope({ dedupe_key: undefined }) }) + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.tag).toBe("FREEZE"); + expect(result.issues.length).toBeGreaterThan(0); + } + }); + + test("rejects text/metadata tag mismatch", () => { + const result = validateCommsTaggedMessage({ + text: "[UNFREEZE] resume", + metadata: messageMetadata() + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.tag).toBe("UNFREEZE"); + expect(result.issues[0]?.path).toEqual(["tag"]); + } + }); +}); + +describe("registry integration", () => { + test("comms schemas dispatch through validateContract and validateEmbeddedContract", () => { + expect(validateContract(SCHEMA_IDS.commsEventEnvelope, envelope()).success).toBe(true); + expect(validateContract(SCHEMA_IDS.commsChannelMetadata, channelMetadata()).success).toBe(true); + expect(validateContract(SCHEMA_IDS.commsMessageMetadata, messageMetadata()).success).toBe(true); + + const embedded = validateEmbeddedContract(messageMetadata()); + expect(embedded.success).toBe(true); + if (embedded.success) { + expect(embedded.schemaId).toBe(SCHEMA_IDS.commsMessageMetadata); + } + }); +}); diff --git a/tests/examples.test.ts b/tests/examples.test.ts index 6a6f1c2..762b629 100644 --- a/tests/examples.test.ts +++ b/tests/examples.test.ts @@ -6,6 +6,9 @@ import { ContractSchemaRegistry, SCHEMA_IDS, type KnownSchemaId, validateContrac const examplesDir = join(import.meta.dir, "..", "examples"); const expectedInvalidIssuePaths: Record = { "app-cloud-manifest.invalid.json": ["cloudResources.0.ownerPackage", "dependencies", "forbiddenSharedRuntimes", "packageName"], + "comms-channel-metadata.invalid.json": ["owner", "until"], + "comms-event-envelope.invalid.json": ["affected_packages", "action_required"], + "comms-message-metadata.invalid.json": ["envelope.severity", "tag"], "integration-ref.invalid.json": ["uri"], "no-cloud-evidence-pack.invalid.json": ["checks", "checks", "findings"], "project-manifest.invalid.json": ["slug"],