diff --git a/AGENTS.md b/AGENTS.md index 2ce11e5..9a2d212 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,7 @@ Guidance for any agent (or human) working in this repo. `CLAUDE.md` is a symlink 2. **Fixture-semantics** — *"does this fixture actually test what it claims?"* See the **Fixture quality bar** in `fixtures/asyncapi/README.md` (no vacuous values; claim↔content; full-path/both-direction coverage; internal consistency; negative cases). The validity-only angle misses all of these. ## Status & next -All build tiers and v1 gates are `tested` (34 of 36 requirements): `model` → `broker`/`registry`/`ingestion` → `engine`/`validation` → `scenarios`/`control-plane` → `cli` (the full verb set incl. `up`/`down` process management over the G14 runfile, watch modes, `init`), plus the four cross-cutting v1 gates (R-028–R-031). The open items: the two empirical spikes, `R-006` (WS-fidelity) and `R-007` (capture the browser application's `connect()`), which need the real browser application against `broker/`'s Aedes defaults and are hard gates on calling v1 done; plus `R-033` (`tested`): the `demo-app/` spike-harness webapp + connect fingerprint (`docs/specs/demo-app.md`), which rehearses both spikes — the at-work capture is now a no-app-change procedure (point the real client at offbook, read `offbook logs`). The adoption surface is `tested` (R-034–R-036: README + guides with executable quickstart/cookbook gates, `offbook doctor`, the first-run error audit — docs/specs/adoption.md). +All build tiers and v1 gates are `tested` (37 of 39 requirements): `model` → `broker`/`registry`/`ingestion` → `engine`/`validation` → `scenarios`/`control-plane` → `cli` (the full verb set incl. `up`/`down` process management over the G14 runfile, watch modes, `init`), plus the four cross-cutting v1 gates (R-028–R-031). The open items: the two empirical spikes, `R-006` (WS-fidelity) and `R-007` (capture the browser application's `connect()`), which need the real browser application against `broker/`'s Aedes defaults and are hard gates on calling v1 done; plus `R-033` (`tested`): the `demo-app/` spike-harness webapp + connect fingerprint (`docs/specs/demo-app.md`), which rehearses both spikes — the at-work capture is now a no-app-change procedure (point the real client at offbook, read `offbook logs`). The adoption surface is `tested` (R-034–R-036: README + guides with executable quickstart/cookbook gates, `offbook doctor`, the first-run error audit — docs/specs/adoption.md). The AsyncAPI support range is declared and hardened (R-037–R-039, D-018): **2.0.0–2.6.0, 3.0.0, 3.1.0**, payloads validated under **draft-07**, with the R-028 gate extended over `multi-format.yaml` (3.1.0) and `v2-oldest.yaml` (2.0.0). ## Working notes - **Git identity is the user's to set** — don't run `git config user.*` on their behalf. Commit/push **only when asked**. diff --git a/DECISIONS.md b/DECISIONS.md index 491823c..3ff917b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -44,6 +44,7 @@ Append-only. Each decision has a stable never-reused `D-###` id, what was decide **Why**: `@asyncapi/parser` 3.x ships only **draft-07** schema parsers (the AsyncAPI default format and `application/schema+*;version=draft-07`, both draft-07-based); draft-07 ignores `$ref` siblings, so the keyword is normalized away during parse — before Ajv-2020 (which *would* honor it) ever sees the schema. The faithful fix (register a custom 2020-12 pass-through schema parser) is **not a small change**: a naive pass-through collapses the payload model to a **vacuous** schema that accepts everything (probed and confirmed), so it is genuinely spike-shaped. R1 forbids the hand-rolled `$ref`-walking alternative. Shipping the achievable bar plus a pinned tripwire is more honest than a broken fixture (option: leave it unparseable) or holding R-004 open on an unbounded spike (option: spike-A-now); owner chose pragmatic-now. **Mitigations / notes**: the external `pattern` **is** inlined + enforced (negative case covered in `src/registry/index.test.ts`); `buildRegistry` gained an optional `source` so cross-file `$ref`s resolve, and parse failures now surface the parser's diagnostics instead of a bare "failed to parse". The tripwire (`external-ref: KNOWN LIMITATION (D-005 …)`) asserts the current drop so the regression is loud, not silent. **Obligations (deferred spike)**: register a JSON-Schema-2020-12 schema parser with `@asyncapi/parser` (or equivalent) so `$ref` siblings survive to Ajv-2020; then re-point the tripwire to assert enforcement and revisit §12.4. Until then, the §5 bar's sibling-under-2020-12 clause is explicitly **not** met. +**Superseded by**: D-018 (2026-07-30). offbook now validates under **draft-07**, the dialect both spec majors declare for the Schema Object and the one the parser emits, so the dropped `$ref` sibling is **dialect-correct behavior rather than a limitation** and the 2020-12 schema-parser spike is no longer an obligation. The tripwire is retained and reworded (`external-ref: a $ref-sibling keyword is not enforced, which is draft-07-correct`) so that a future dialect change, which would start enforcing the sibling, stays loud. **From**: R-004 build dialog (2026-07-04), owner chose the pragmatic path over spiking the custom parser now **Folds into**: fixtures/asyncapi/external-ref.yaml, fixtures/asyncapi/README.md, src/registry/index.ts (`source` param), src/registry/index.test.ts (tripwire), REQUIREMENTS.md (R-004) @@ -133,3 +134,23 @@ Append-only. Each decision has a stable never-reused `D-###` id, what was decide **Why**: The full local gate set existed but nothing enforced it at merge time. One serial job because the whole set runs in ~40 s locally, so parallel jobs would only add per-job runner boot and install overhead. Bun is pinned because the bunfig.toml coverage-gate semantics (per-file floors; silent exit-1 as the only failure signal) were empirically verified on 1.3.14, and a floating runtime could silently change what the test gate means; bumping the pin is a deliberate one-line PR the gates themselves validate. A compiled-binary artifact was dropped after two verified findings (the extensionless `bin/offbook` makes `bun build --compile` emit a 1-module no-op binary, and with that fixed the Spectral/nimma dynamic `require()`s still break the compiled binary at startup: `Cannot find module './impl/format'`); single-binary offbook is deferred as its own project. **From**: docs/superpowers/specs/2026-07-30-github-ci-design.md (design dialog, 2026-07-30) **Folds into**: .github/workflows/ci.yml, package.json (`typecheck`), AGENTS.md (working notes), README.md (badge) + +### D-018: offbook supports AsyncAPI 2.0.0-3.1.0, validates payloads under draft-07, and polices bindings itself +**Date**: 2026-07-30 +**What**: Declare a tested support matrix (2.0.0-2.6.0, 3.0.0, 3.1.0; 1.x refused), raise the `@asyncapi/parser` floor to `^3.6.0`, gate the version in `registry/` before `parse()`, and harden the shared ingestion path: unwrap the Multi Format Schema Object payload wrapper, validate under JSON Schema draft-07 with an explicit stamp instead of stamping 2020-12, diagnose post-draft-07 keywords, contain Ajv compile failures, validate multi-message operations as `anyOf`, and police mqtt binding values and keys against the official binding schema's key set. Supersedes D-005's deferred 2020-12 obligation. +**Why**: Three defects were found by research, none by a failing test. A valid 3.x multi-format payload made the channel validator accept everything including `null`, because `BaseModel.json()` returns the wrapper verbatim; that is the false-negative class R-028 exists to prevent. A legal draft-07 tuple schema crashed `bootProject` uncaught, because offbook stamped 2020-12 over a schema the parser emits as draft-07. 2.x maps `mqtt` to an empty schema, so `qos: 9` reached a `Channel` typed `0 | 1 | 2`. Converting 2.x to 3.x on ingest was considered and rejected: the fleet is mostly 3.x, so it would add a dependency and a lossy translation layer to serve a shrinking minority, and it would anchor diagnostics to a document the adopter never wrote. Draft-07 is the dialect BOTH majors declare and the parser actually emits, so validating under it is the root-cause fix and needs no schema rewriting (R1 intact). +**Mitigations / notes**: Draft-07 silently ignores post-draft-07 keywords, so a dialect-mismatch diagnostic makes that loud. The explicit supported-version list is cross-checked by a test, because deriving it from `@asyncapi/specs` reproduces the trap that fooled parser 3.4.0/3.5.0. New findings reuse the existing closed `spec-load` diagnostic kind with a machine-greppable `detail` prefix rather than expanding the `Diagnostic.kind` union. A 2.x spec authored from the client's perspective still loads inverted and no tool can detect that; it is documented for adopters. +**Discovered during implementation (2026-07-30)**: **json-schema-faker 0.6.2 cannot draw a valid draft-07 tuple.** Measured over the `{type: array, items: [{type: string}, {type: number}]}` shape: it emits objects with numeric keys (for example `[{"0":"aG88rL","1":441.48}, ...]`) and fails the Ajv recheck **10/10 seeds**, unchanged by `additionalItems` or `minItems`. Consequences: validation of tuples is correct under draft-07 (positional, `additionalItems` honored), but a tuple-shaped `toClient` payload has **no usable L1 fake** and falls to F5 drop-and-surface. The tuple regression case therefore lives as an **inline test spec**, not in `fixtures/asyncapi/`: a tuple fixture would fail the R-027 faker-floor spike and flip **D-008**'s measured verdict ("no §5-bar fixture failed ⇒ F5's drop-and-surface stands and the L1 floor needs no keyed-fallback re-draw"), which is a separate question from the dialect. Whether the L1 floor should gain a keyed-fallback re-draw for shapes json-schema-faker cannot serve is left open, deliberately unresolved here. +**Obligations**: none deferred. +**From**: brainstorm dialog 2026-07-30 after a deep-research pass; every behavioral claim verified empirically against the installed toolchain (results tabulated in the design doc). +**Folds into**: src/model/spec-version.ts, src/registry/index.ts, src/ingestion/index.ts, docs/specs/contracts.md §5/§6, fixtures/asyncapi/, REQUIREMENTS.md (R-037-R-039), DECISIONS.md (D-005 superseded note) + +### D-019: the version gate refuses only what it recognizes, and upstream constants are hand-authored behind a drift gate +**Date**: 2026-08-01 +**What**: Amends D-018 on three mechanisms. (1) The R-037 preflight fires only on a version it positively read and positively recognizes as untested; an unreadable or absent `asyncapi` field falls through to `@asyncapi/parser`, and the refusal message names every tested version instead of a range. (2) The alignment between offbook's supported set and the parser's capability is enforced by an install-time drift test (`test/upstream-drift.test.ts`), not a runtime post-parse recheck. (3) `@asyncapi/specs` becomes a devDependency and the mqtt operation-binding key set becomes a hand-authored constant in `registry/`, transcribed including the `x-` vendor-extension pattern, with the same drift test pinning it and a source-text check keeping it out of `src/`. +**Why**: Three defects found in PR #2 review. The preflight treated `readSpecVersion`'s `undefined` as "unsupported version", so malformed YAML, an empty file, and a fetched HTML error page all died advising `asyncapi convert`; spec-load failure aborts `up` in the foreground, so that was the whole error an adopter saw, and it fails the R-036 bar of naming what actually failed. The parser's own diagnostics already distinguish those cases precisely, so owning branded messages for them would duplicate correct text and put a second YAML reader in a position to disagree with the parser's. `@asyncapi/specs` was imported but undeclared, a transitive of `@asyncapi/parser` resolved only by flat hoisting, at an unversioned internal subpath the package's absent `exports` map does not guarantee. Deriving the binding key set at runtime also read only `properties`, dropping the schema's `patternProperties` allowance, so a spec-legal `x-vendor-thing` was reported as an unknown key. +**Mitigations / notes**: A runtime `document.version()` recheck was considered for the gap the preflight leaves (a version the parser accepts and offbook does not) and rejected: it is structurally unreachable while the two sets agree, which they do, so it would ship as a permanently uncovered `throw` under the per-file coverage floor. The drift test catches the same divergence at the dependency bump instead. The re-import guard is a source-text check rather than a Biome `noRestrictedImports` rule because Biome 1.9 matches whole module specifiers: a bare `@asyncapi/specs` entry does not flag `@asyncapi/specs/bindings/mqtt/0.2.0/operation.json` (verified both ways), which is precisely the import shape being prevented. The drift test does not reopen what D-018 rejected: the constants stay hand-authored and separately tested against the real parser, and the test only makes upstream drift loud. +**Discovered during implementation (2026-08-01)**: an unquoted `asyncapi: 2.6` (a YAML float, not a string) makes `@asyncapi/parser` 3.6.0 die inside `getSemver` with a raw `TypeError: undefined is not an object (evaluating 'patchWithRc.split')` stack dumped into a diagnostic. The preflight catches it first because `readSpecVersion` normalizes with `String()`, which retroactively justifies that normalization as load-bearing rather than defensive. That normalization was not yet complete: a present-but-null `asyncapi` field (bare `asyncapi:`) still collapsed to `undefined`, the absent-field case, and fell through to the same parser TypeError. Closed by reading any POSITIVELY PRESENT `asyncapi` value, null included, since the parser's internal TypeError covers null, empty string, boolean, and object values alike, not just the unquoted-float case. +**Obligations**: none deferred. +**From**: code review of PR #2 (findings 1-3), then a design dialog on 2026-08-01; every behavioral claim verified empirically against the installed toolchain. +**Folds into**: src/registry/index.ts, package.json, test/upstream-drift.test.ts, REQUIREMENTS.md (R-037, R-039), docs/specs/build-plan.md §1 diff --git a/README.md b/README.md index c294ce7..4cd509d 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ other side of every topic and tells you when either side breaks the contract. - [Bun](https://bun.sh) >= 1.3 (the `engines.bun` floor in `package.json`) - git access to the host your AsyncAPI spec repos live on +- AsyncAPI specs at **2.0.0-2.6.0, 3.0.0, or 3.1.0** (3.1.0 recommended). AsyncAPI 1.x is not supported: convert it with `asyncapi convert` first. ## Quickstart (zero config) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index b34935e..bba0a0d 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -40,7 +40,7 @@ A browser-style `mqtt.js` client connects to the Aedes ws listener over MQTT 3.1 **COVERS**: docs/specs/build-plan.md#tier-1 **IMPL**: src/registry/ **TEST**: src/registry/index.test.ts -`registry/` parses every `fixtures/asyncapi/*` (including external-ref, qos-retain, qos-overrides), resolves channel direction (v2 + v3) and the qos/retain precedence chain, and its `match`/`matchesFilter` behave per the §5 correctness bar (the 2020-12 `$ref`-sibling edge is carved out to D-005, pinned by a tripwire test). +`registry/` parses every `fixtures/asyncapi/*` (including external-ref, qos-retain, qos-overrides), resolves channel direction (v2 + v3) and the qos/retain precedence chain, and its `match`/`matchesFilter` behave per the §5 correctness bar (payloads validate under the draft-07 dialect both majors declare, so a `$ref`-sibling keyword is dialect-correctly not enforced, pinned by a tripwire test — D-018 supersedes D-005). #### ingestion/ branch-tip fetch and lockfile writer **UID**: R-005 @@ -294,6 +294,30 @@ A preflight verb running a fixed ordered list of named checks (runtime floor via **TEST**: test/readme-quickstart.test.ts, test/guides-cookbook.test.ts, test/cli-dispatch.test.ts Every error reachable on the clone→demo→init→wire→up→first-publish path names what failed plus one concrete next step (with a "(try `offbook doctor`)" suffix only where doctor genuinely diagnoses it), pinned by tests; the README quickstart and scenario-cookbook recipes are executable docs — the quickstart gate runs the canonical command sequence and asserts fence↔canonical equivalence, the cookbook gate loads every recipe against the bundled demo registry with zero diagnostics. +#### AsyncAPI supported-version contract and preflight +**UID**: R-037 +**STATUS**: tested +**COVERS**: docs/superpowers/specs/2026-07-30-asyncapi-version-support-design.md +**IMPL**: src/model/spec-version.ts, src/registry/index.ts, src/ingestion/index.ts, src/cli/boot.ts +**TEST**: src/model/spec-version.test.ts, src/registry/index.test.ts, src/ingestion/index.test.ts, test/gate-validation.test.ts, test/upstream-drift.test.ts +`registry/` refuses any spec whose declared version it can read and has not tested (the set is 2.0.0-2.6.0, 3.0.0, 3.1.0) with a branded, actionable error naming the version, every tested version, and the convert remedy, checked parser-free before `parse()`; an unreadable or absent `asyncapi` field defers to the parser's own diagnostics rather than being guessed at as a version problem (D-019), and a drift test keeps the tested set aligned with what `@asyncapi/specs` exposes; the declared version is recorded as `spec-version` in `specs.lock` and on `SpecInfo`. + +#### AsyncAPI payload schema boundary +**UID**: R-038 +**STATUS**: tested +**COVERS**: docs/superpowers/specs/2026-07-30-asyncapi-version-support-design.md +**IMPL**: src/registry/index.ts +**TEST**: src/registry/index.test.ts, test/gate-validation.test.ts +`registry/` extracts the payload schema from the Multi Format Schema Object wrapper, validates under draft-07 with an explicit stamp, diagnoses post-draft-07 keywords it cannot honor, contains a compile failure as a violation rather than a crash or a green pass, and validates an operation's multiple messages as `anyOf`. + +#### MQTT binding integrity across spec majors +**UID**: R-039 +**STATUS**: tested +**COVERS**: docs/superpowers/specs/2026-07-30-asyncapi-version-support-design.md +**IMPL**: src/registry/index.ts, src/model/index.ts, src/compose/index.ts +**TEST**: src/registry/index.test.ts, test/gate-validation.test.ts, test/upstream-drift.test.ts +`registry/` guards binding-supplied `qos`/`retain` values (falling through the §2 precedence chain on a bad value), reports unknown keys against a hand-authored mqtt operation-binding key set that honors the schema's `x-` vendor-extension pattern and is drift-tested against `@asyncapi/specs` (a devDependency, never imported from `src/`; D-019), reports an mqtt CHANNEL binding as ignored, and reports MQTT-5-only binding fields as unhonored under the MQTT 3.1.1-only constraint. +