Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5a3f5ca
docs: design AsyncAPI 2.0.0-3.1.0 support hardening
nzneit Jul 30, 2026
32c4783
docs: add the AsyncAPI 2.0.0-3.1.0 support implementation plan
nzneit Jul 30, 2026
eded46c
feat(registry): gate the AsyncAPI supported-version contract (R-037)
nzneit Jul 30, 2026
de29eae
fix(registry): unwrap the Multi Format Schema Object payload (R-038)
nzneit Jul 30, 2026
9c312e8
docs: correct the plan for the R-027 spike coupling and import style
nzneit Jul 30, 2026
9425f23
fix(registry): validate payloads under draft-07, not 2020-12 (R-038)
nzneit Jul 30, 2026
c3e9809
docs: amend plan Task 3 for the json-schema-faker tuple finding
nzneit Jul 30, 2026
d9896ff
docs: add the D-005 decision sweep to plan Task 10
nzneit Jul 30, 2026
e3c662a
feat(registry): carry registry-time diagnostics to /v1/diagnostics (R…
nzneit Jul 30, 2026
ebd9340
feat(registry): diagnose post-draft-07 keywords (R-038)
nzneit Jul 30, 2026
626deb7
fix(registry): contain an uncompilable payload schema (R-038)
nzneit Jul 30, 2026
1df0fa8
fix(registry): validate all of an operation's messages as anyOf (R-038)
nzneit Jul 30, 2026
0ee6a00
fix(registry): guard mqtt binding values and keys (R-039)
nzneit Aug 1, 2026
5f8c131
feat(ingestion): record the AsyncAPI spec version as provenance (R-037)
nzneit Aug 1, 2026
f6abb11
docs: declare the AsyncAPI support range and close R-037..R-039
nzneit Aug 1, 2026
c28b8a1
docs: design the AsyncAPI version gate hardening (amends D-018)
nzneit Aug 1, 2026
57e374d
docs: make the re-import guard a source-text check, not a lint rule
nzneit Aug 1, 2026
3521f84
docs: add the AsyncAPI version gate hardening implementation plan
nzneit Aug 1, 2026
9a7492f
fix(registry): refuse only versions the preflight recognizes (R-037)
nzneit Aug 1, 2026
026c0e6
fix(registry): make @asyncapi/specs a devDependency (R-039)
nzneit Aug 1, 2026
4a68a1e
test: gate offbook's hand-authored constants against @asyncapi/specs
nzneit Aug 1, 2026
8e2f592
fix(test): close require() evasion in the upstream-drift devDep gate
nzneit Aug 1, 2026
fc6d8dd
docs: record D-019 and amend R-037/R-039
nzneit Aug 1, 2026
b6abb80
fix(model): treat a present-but-null asyncapi field as positively read
nzneit Aug 1, 2026
4cdf60e
fix(test): harden the upstream-drift gate against three ways it could…
nzneit Aug 1, 2026
caa0043
fix(test): tolerate irregular whitespace in the transport-isolation r…
nzneit Aug 1, 2026
281bbd7
docs: close the D-019 provenance gap and explain the as Error casts
nzneit Aug 1, 2026
0430828
docs(test): correct the dynamic-import example in the guard comment
nzneit Aug 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
21 changes: 21 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
26 changes: 25 additions & 1 deletion REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

<!--
Seeding is staged (doc-system.md §7). Batch 1 (R-001..R-007) + R-008 (M0) + R-009 (broker tier-1 residual): seeded; reconciled where traces exist (the R-006/R-007 spikes remain open). Batch 2+ (R-010..R-031): the full module/spike/gate carve per D-007 and docs/archive/intake/2026-07-21-batch-2-seeding-carve.md.
What remains unseeded resolves case by case (not bulk):
Expand Down
3 changes: 2 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading