Skip to content

Protocol v1 - Turn Ashurbanipal into a protocol: spec, conformance kit, and two new ports#5

Open
mt-empty wants to merge 16 commits into
mainfrom
protocol-v1
Open

Protocol v1 - Turn Ashurbanipal into a protocol: spec, conformance kit, and two new ports#5
mt-empty wants to merge 16 commits into
mainfrom
protocol-v1

Conversation

@mt-empty

Copy link
Copy Markdown
Owner

Ashurbanipal started as a single Rust/Axum crate. This branch inverts
that: the frontend and a normative protocol are now the product, and
every backend — including the original Rust crate — is a peer
implementation conforming to them, none structurally privileged.

  • spec/protocol.md + spec/openapi.yaml — the normative endpoint
    contract (transport, mount contract, kill switch, the five routes,
    server invariants, protocol versioning), extracted from docs/design.md
    and made authoritative. spec/CHANGELOG.md tracks version history
    going forward.
  • Filter grammar moved to the frontend. DSL-text parsing
    (spec/filter-dsl.md) is now a frontend-only concern; every backend's
    obligation is defined purely against the JSON filter AST
    (spec/fixtures/filter-builder-tests.json), shrinking each port's
    filter code to validation + WHERE-clause building — no tokenizer, no
    grammar, no quoting rules to replicate per language.
  • A two-layer conformance kit (conformance/): a golden-fixture
    behavior runner (conformance/runner, HTTP-only, works against any
    running instance via ASHURBANIPAL_CONFORMANCE_URL) and schemathesis
    shape-fuzzing against spec/openapi.yaml — both wired into reusable
    CI templates (_conformance-behavior.yml, _conformance-schema.yml)
    every implementation's workflow calls into.
  • The frontend ships as a versioned, vendorable artifact
    mount-point agnostic (derives its API base from location.pathname),
    released with a sha256 and NOTICE on tag, so a port vendors it rather
    than forking it.
  • Two new ports, built sequentially so each surfaced spec gaps before
    the next started: the Spring Boot starter (Kotlin autoconfiguration
    module) and the Go net/http port (framework-agnostic library).
    Both pass both conformance layers and carry their own fail-closed
    kill-switch tests.
  • PORTING.md is now a complete guide — what a port is, what it
    reuses, what it implements (keyed to spec section numbers), what it
    must not do, how to run conformance locally and in CI, the listing
    bar, and a governance section (one-PR-touches-spec-and-fixtures-and-
    implementations rule, protocol versioning policy, registry staleness)
    — plus a seven-item cross-port hardening checklist covering the
    properties neither conformance layer can observe (cast-in-SQL
    location, fail-closed-on-absent-config, injection-absence, vendoring
    integrity, kill-switch self-certification, catalog-SQL parity), signed
    off against all three implementations.
  • No privileged reference implementation — the Rust crate, Spring
    Boot starter, and Go port are three equal rows in readme.md's
    registry, each with its own conformance CI link.

Explicitly out of scope for this branch (recorded as deliberate, not
missing): sidecar guidance for unsupported stacks (pgweb recommendation),
a third port (Elixir/Phoenix — cancelled, not deferred), and actually
cutting the first tagged release.

Test plan

  • mise run check (or the Rust/Spring/Go/frontend/conformance
    umbrella tasks individually) green
  • conformance/runner/report.sh and conformance/runner/schema-check.sh
    pass against all three implementations' demos
  • Spot-check mise run rust:demo against the seeded devcontainer db

mt-empty added 16 commits July 22, 2026 14:27
Derive the API base from location.pathname instead of hardcoding
/__ashurbanipal/api, so the UI works behind any reverse-proxy prefix.
Demo gains an optional MOUNT_PREFIX env var to simulate one; covered by
a new e2e spec and a black-box regression guard against the literal
returning.
spec/protocol.md and spec/openapi.yaml become the cross-implementation
contract (docs/design.md §4 stays as rationale); spec/filter-dsl.md
moves under spec/, rescoped to the frontend's grammar. The filter
query param's wire format changes from DSL text to a JSON AST -
dbviewer.html gains the grammar parser and parses client-side at
submit; src/filter.rs drops its tokenizer for AST deserialization +
structural validation. Backed by shared fixtures
(spec/fixtures/parser-tests.json, filter-builder-tests.json) consumed
by both the Rust suite and a new Playwright spec.

Also hardens the reference to match the spec: statement timeout on
every query (previously only table-data queries were bounded),
current_schema() instead of hardcoded 'public', an open enabled_for
vocabulary (production-like names still rejected at config load), and
an x-ashurbanipal-protocol response header on every API route.
Repurposes tests/black_box/ into conformance/runner/, adding an
external-URL mode (ASHURBANIPAL_CONFORMANCE_URL) so the same suite can
target a port instead of only the spawned reference demo. Backed by a
new conformance/seed/ (extends tools/seed-gen with composite-FK,
no-ANALYZE, and cross-schema fixtures) and a coverage matrix mapping
every MUST in spec/protocol.md to a test, with honest gaps documented
rather than silently skipped.

Assertions go through four explicit comparison tiers instead of ad hoc
equality checks - exact match, type/range only (total_approx,
common-values frequencies), status-code only (error responses, since
body text is implementation-defined), or not checked at all - so a
reviewer doesn't have to reclassify each assertion by eye.

docs/design.md is replaced with the design-alt.md draft: it's what the
project has actually been following since the three-layer conformance
model (schema generation, schema fuzzing, behavioral fixtures) was
worked out, so design-alt.md's content becomes the real design doc
rather than living as a parallel draft.
The Rust crate moves to implementations/rust/ so it's structurally a
peer, not a reference other implementations port from - Spring Boot,
Go, and Elixir land as siblings under implementations/ later, not
ports/. Pulls Phase 3's frontend move forward too: dbviewer.html was
already going to be released as a shared artifact, and it can't stay
nested inside one implementation's source tree once no implementation
is privileged, so it moves to a repo-root frontend/ now instead.

spec/openapi.yaml goes back to hand-maintained (utoipa is dropped) -
layer 1 of the conformance model stops being "structurally can't
drift" and becomes governance: one PR touches spec/protocol.md +
spec/openapi.yaml + fixtures together, and drift between the schema
and actual behavior is caught after the fact by layer 2's schema
fuzzing rather than prevented by construction. That tradeoff is stated
explicitly in docs/design.md rather than papered over.

Also fixes a table-listing e2e test that's been broken since the
conformance kit landed (85a7eb7 added four tables to the seed without
updating this fixture's hardcoded list), caught while re-running the
full suite after the move.
Adds the protocol-version stamp to dbviewer.html, a NOTICE for
implementations that vendor it outside a repo clone, a PORTING.md stub
covering just the vendoring contract (pin a tag, record the sha256,
re-verify it in your own CI), and a release workflow that attaches the
frontend, LICENSE, and its checksum to a GitHub Release on tag push.

crates.io publishing stays deliberately out of this workflow - it's a
one-way action needing a registry token, not something to bundle in by
default.
loadData()'s resetScroll path only zeroed #main's vertical offset;
switching to a narrower table could leave the viewport scrolled right
with nothing to show.
New conformance/runner/schema-check.sh fires schemathesis at a running
implementation - spawned by default, or ASHURBANIPAL_CONFORMANCE_URL
to target an external one, mirroring the layer-3 runner's split. Wired
into a separate CI job so shape and behavior failures never interleave,
plus mise tasks for local runs.

Fuzzing surfaced real drift between spec/openapi.yaml and actual
behavior: `limit` rejected out-of-range values with 400 instead of
clamping them, violating protocol.md's explicit MUST. Fixed both
directions - the deserializer now saturates negative and overflowing
values before the handler's own clamp() runs, not just the overflow
case initially caught. The root cause was in the schema itself:
openapi.yaml declared `minimum: 0` on `limit`, which is a JSON Schema
rejection constraint - self-contradictory for a field whose contract
is "never rejected, only clamped". Removed it; a schema can't
accurately describe a field it also mis-declares.

Two `positive_data_acceptance` cases stay excluded, documented rather
than suppressed: identifier validity (empty/unknown table or column)
is a runtime information_schema lookup no static schema can express,
and the filter AST's positional logic rule (absent on element 0,
required after) isn't expressible via JSON Schema's uniform `items`.
offset had the identical bug limit did: a negative or overflowing value
400'd instead of clamping, and spec/openapi.yaml declared the same
false minimum: 0 rejection constraint. Unlike limit's fix this wasn't
caught by schemathesis - /tables/data's own params weren't re-fuzzed
after the limit fix - but the two fields go through the same
deserializer and the same reasoning applies: a negative offset has an
obvious clamp target (0, same as omitting it), and an oversized one
already degrades harmlessly today (SQL OFFSET past the table just
returns nothing, per the existing offset_is_unclamped_and_beyond_table_
size_returns_empty_rows test).

protocol.md was previously silent on offset's out-of-range behavior
(unlike limit's explicit "never an error") - added the same MUST so
the spec and implementation don't just happen to agree by coincidence.
implementations/spring-boot-starter/: an @autoConfiguration starter
that mirrors the Rust reference's fail-closed kill switch, filter AST
validation, and catalog queries (composite-FK omission, current_schema()
scoping, ::text-cast serialization). No Docker available here, so
integration tests hit the same live $DATABASE_URL every other suite in
this repo already uses instead of Testcontainers.

Both conformance layers pass against a live instance, independently
re-verified rather than just accepting the build log: the golden-fixture
runner (40/40) and schemathesis (346/346) produce identical numbers to
the Rust reference's own runs against the same spec.

Writing a second implementation surfaced a real spec gap: protocol.md
was silent on *where* the ::text cast must happen, which matters because
a JVM decode-then-toString() step can silently diverge from Postgres's
own cast (locale, timezone) while still technically returning a string.
Added the MUST. PORTING.md gains the CSP/inline-script section the
hardening checklist called for - dbviewer.html's inline <script> breaks
under a strict CSP identically for every port, not just this one.

No publishing wired up (no Maven Central credentials, matching the same
stance already taken on crates.io) - the workflow builds and re-verifies
the vendored frontend's hash on every run but never ships anywhere.
… 9.6.1

- Replace io.spring.dependency-management with Gradle-native platform() BOM import
- Migrate Jackson 2 -> Jackson 3 (tools.jackson.*), Spring Boot 4's new default
- Fix JsonNode.map member/extension shadowing at call sites (cast to Iterable<JsonNode>)
- Explicitly enable FAIL_ON_UNKNOWN_PROPERTIES (no longer Jackson 3's default)
- Add <T : Any> bound to apiOk for Kotlin 2.2 + Spring Framework 7 null-safety
- Replace deprecated 'by tasks.registering' with tasks.register (Gradle 9.6+)
Group tasks under rust:*, spring:*, frontend:*, conformance:* with
umbrella tasks (mise run rust/spring/frontend/conformance) and a
top-level check depending on all four. Adds new spring:build/test/check
tasks for implementations/spring-boot-starter (previously had none).
Updates all cross-references to renamed task names in CI, docs, and
e2e test comments.
Extract the 'start demo, run conformance kit, run schemathesis' steps
duplicated across implementations into two workflow_call templates:
_conformance-behavior.yml (layer 3) and _conformance-schema.yml (layer
2), kept as separate jobs so a shape failure and a behavior failure
never interleave in one log.

conformance.yml (Rust) is replaced by conformance-rust.yml, which calls
both templates with Rust-specific inputs. spring-boot-conformance.yml
keeps its own gradle build-and-test job but now calls the same two
templates for its conformance/schema-conformance jobs instead of
duplicating the steps inline. Future ports (Go, etc.) add a thin
per-implementation trigger file that calls the same templates rather
than copy-pasting the whole sequence.

Updates readme.md/PORTING.md references to the renamed workflow file.
A net/http-native library at implementations/go-nethttp: func Router(cfg
Config, db *sql.DB) (http.Handler, error) mounts the six routes (UI +
five API) into any net/http-compatible mux, with no framework choice
baked in. Fail-closed via the error return on a production-like
EnabledFor (Config{} zero value is disabled by construction, matching
the Rust and Kotlin ports); catalog SQL ported line-for-line against
db.rs (information_schema/pg_stats queries, composite-FK omission,
current_schema() scoping, ::text-cast-in-SQL for value serialization);
filter AST validation and WHERE-clause building ported against
filter.rs, with a defense-in-depth operator re-check in
BuildWhereClause since Go's plain string Condition.Op has no
type-level guarantee the way Rust's FilterOp enum does. Siblings
health fan-out via errgroup + per-check context timeout. Vendored
frontend/dbviewer.html re-hashed against the pinned sha256 on every
package load (embed.go), not just recorded once.

Tests: filter_fixture_test.go runs all 30 cases from
spec/fixtures/filter-builder-tests.json directly; killswitch_test.go
covers the fail-closed contract (zero-value disabled, production-like
EnabledFor errors, production-like running environment silently
disables); integration_test.go boots a real httptest.Server against
the live seeded DATABASE_URL. 65/65 tests green under -race.

Verified via cmd/demo against both conformance layers: 40/40 behavior
(conformance/runner/report.sh) and 346/346 schema
(schema-check.sh), matching the Rust reference's and Spring Boot
starter's own numbers.

Extends the _conformance-behavior.yml/_conformance-schema.yml reusable
templates with a go toolchain branch (actions/setup-go), and adds
go-conformance.yml as the port's own CI trigger, mirroring
conformance-rust.yml/spring-boot-conformance.yml. Adds the registry
row to readme.md.
PORTING.md grows from a vendoring-only draft into the full guide: what a
port is, what it reuses, what it implements (keyed to spec/protocol.md
section numbers), what it MUST NOT do, how to run both conformance layers
locally and in CI, the listing bar, and a Governance section (one-PR
rule, protocol versioning, registry staleness) housing the Phase 5.5
cross-port hardening checklist where it was always meant to live. Sidecar
guidance is deliberately left out of this pass.

The checklist's sign-off log records an actual read-through of all three
implementations, not just a template: confirmed cast-in-SQL, fail-closed
defaults, parameterization, and catalog-SQL parity for Rust, Spring Boot,
and Go, and read each port's kill-switch test directly rather than
trusting green CI. That review surfaced one real gap in the reference
itself — no test covered `enabled_for` being entirely absent from the
TOML text (only the constructed-empty-list case) — closed here with
config::tests::disabled_when_enabled_for_absent_from_config.

Adds spec/CHANGELOG.md, seeded with v1 as the initial version per the
governance section's versioning policy.
…query strings

ci.yml's check job ran `mise run check` (which fans out to
conformance:schema-test) without ever installing schemathesis, so it
failed immediately with "schemathesis not installed" — the reusable
_conformance-schema.yml workflow had its own install step but ci.yml
never got one. Add `mise run conformance:schema-install` before it.

Separately, schemathesis fuzzing found the Spring Boot demo returns 500
for GET /tables/data and GET /tables/common-values when the query
string contains a parameter with an empty name (e.g. `?=true&table=x`).
Tomcat's Parameters.processParameters throws InvalidParameterException
(an IllegalStateException) while Spring resolves @RequestParam, before
any handler code runs; it fell through to the generic 500 handler.
Added a specific IllegalStateException handler mapping to 400, since a
query string the container can't even parse is a client error, not a
server fault. Verified against the seed that found the failures:
schemathesis now reports 0 issues over 746 generated cases.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant