Crosswake.Bridge.CatalogGuard (crosswake v0.2.4)

View Source

Merge-blocking structural guard for the Phase 154 catalog line (CTRL-04, PROOF-04).

This module is a plain support module (NO use ExUnit.Case) callable from the proof lane and from mix crosswake.doctor. It lives in lib/, not test/, for the reason D-43 gives: deleting the test does not delete the rule, and doctor can call the same predicates a developer's CI run does. The guard travels with the code.

It reads the attestation file that already exists — Manifest.Builder's capability catalog — rather than minting a second one. A second catalog would recreate exactly the drift problem it claims to solve, and here it would be five-way drift: new file, catalog, command list, capability-command map, and two native enums (D-42).

The six criteria, labelled honestly (D-44)

This labelling is not decoration. D-45 records that PROOF-04 does not stop a maintainer adding forty controls one string at a time. Stating which criteria are mechanical, which is mechanical only in the negative, and which is hybrid with a later phase is what keeps the requirement from quietly overclaiming.

  • (a) route-local and declarable — MECHANICAL. Universally quantified over every command in the vocabulary, never spot-checked: every shipped command resolves to a declared capability family, and every bounded-bridge catalog entry resolves back to a shipped command (check_attestation/3).
  • (b) low-frequency — MECHANICAL ONLY IN THE NEGATIVE. The guard can prove no streaming seam exists in the bridge tree (check_no_streaming_seam/1). It cannot prove nobody calls a control in a loop. Absence of a seam is provable; discipline in calling is not.
  • (c) zero external SDK — MECHANICAL. An AST allowlist walk over every alias/import/require/use declaration in the bridge tree (check_no_external_sdk/1).
  • (d) semantically bounded — MECHANICAL. Six sub-assertions: the command list is a literal, no dynamic-registration function, no runtime function application, no atom minting outside the frozen allowlist, and native command enum parity against BOTH native sources in BOTH directions.
  • (e) fails closed — HYBRID. This phase asserts the declaration. Phase 155's PROOF-01 route tour asserts it renders. Nothing here proves the denial surface actually appears to a user.
  • (f) backend-authoritative — MECHANICAL BY PROXY. Crosswake.Bridge.Reply's field set is frozen (Plan 03), so a reply has nowhere to put an authority-carrying key. The guard does not re-derive this; it inherits it.

Non-mechanical exclusion — the unbounded host-supplied denial reason

Separately labelled, because it is NOT one of the six and it is NOT covered by the mechanical set above. Five delegate seams accept a bare String denial reason from an adopter host, so no static enumeration can bound them:

  1. packages/crosswake-shell-core-android/src/main/java/dev/crosswake/shell/core/CrosswakeDelegates.kt:39 — data class Denied(val reason: String, ...) on the notification-token delegate.
  2. packages/crosswake-shell-core-android/src/main/java/dev/crosswake/shell/core/FilesPickResult.kt:7 — data class Denied(val reason: String, ...) on the files-pick result.
  3. packages/crosswake-shell-core-ios/Sources/CrosswakeShellCore/BridgeChannel.swift:128 — case unavailable(reason: String, detail: [String: String]).
  4. packages/crosswake-shell-core-ios/Sources/CrosswakeShellCore/BridgeChannel.swift:133 — case deny(reason: String, message: String, hint: String).
  5. examples/android_shell_host/app/src/main/java/dev/crosswake/shell/CrosswakeDelegates.kt:39 — the duplicated example-host copy of seam 1.

Any adopter host can mint an arbitrary reason at any of those five seams. This sub-assertion is NOT mechanically enforceable and is carried by .planning/seeds/SEED-008-native-denial-vocabulary.md, not by this guard. Do not read the six-criteria block above as covering it. The runtime answer already shipped: Crosswake.Bridge's reply decoder resolves an unknown reason string to :unavailable_capability and preserves the raw value at details.raw_reason, so an unbounded reason can neither crash the server nor launder itself into the closed vocabulary.

Known extractor limitation

extract_native_denial_reasons/1 recognises a reason literal only when it is lowercase and contains at least one underscore. Every one of the 14 closed vocabulary reasons and all 8 seeded allowlist entries have that shape, and the underscore requirement is what keeps neighbouring prose and detail-key literals ("unconfigured", "connecting") from being mistaken for reasons. A single-word reason literal would slip past. Named here rather than hidden.

AST mechanism

Code.string_to_quoted/2 + Macro.prewalk/3 only — stdlib, no new dependency, no cross-reference tooling. Mirrors Crosswake.CompanionGuard exactly, including its documented child-module prefix-match pitfall when matching alias node parts.

The injection seam, and why it is not a loophole

assert_catalog_closed!/1 accepts an optional root: plus the three compiled inputs it would otherwise read from Contract, Registry, and Manifest.Builder. Every default is the real shipped value, so the zero-argument call — the one CI and mix crosswake.doctor make — is byte-for-byte the gate it was before the seam existed. Nothing is relaxed and no violation is skippable.

The seam exists so test/crosswake/proof/phase154_recipe_followable_test.exs can EXECUTE the six-step recipe this module's failure message prints, against a synthetic control in a temp tree, and prove the gate goes red-to-green across the steps and red again when any single step is omitted. Before the seam, that test could only re-compose the individual predicates and hope the composition matched the raiser's; now it drives the raiser itself. A gate whose documented path to yes is never executed is a gate nobody has checked the exit door on.

Summary

Functions

Walks the real shipped sources and raises on the first violated criterion, with the six-step recipe for legitimately adding the next control.

The bounded-bridge capability families declared in Manifest.Builder's capability catalog — the attestation file that already exists (D-42).

The lib/ sources the catalog line is enforced over.

Criterion (a): every catalog entry maps to a shipped command and every shipped command maps to a catalog entry.

Criterion (d): the command vocabulary must be a compile-time literal.

Asserts every allowlist entry is still emitted by at least one of its declared sites. An entry whose string has been fixed or deleted is rot: it makes the allowlist look larger than the real debt and quietly widens the gate.

Asserts every statically extractable native denial reason is either in the closed Crosswake.Shell.Denial vocabulary or on the eight-entry seeded allowlist. A NINTH out-of-vocabulary string is a violation.

Criterion (d): native command enum parity, asserted in BOTH directions.

Criterion (d): no atom minting. String.to_atom/1 and List.to_atom/1 grow the atom table from wire input. String.to_existing_atom/1 is on the frozen allowlist — it cannot mint, only resolve.

Criterion (d): no dynamic-registration seam.

Criterion (c): zero external SDK. An allowlist walk over every alias/import/require/use declaration; anything rooted outside [:Crosswake, :Phoenix, :Logger, :Jason, :Kernel] is an external SDK reaching into the bounded bridge.

Criterion (d): no runtime function application. apply/2, apply/3, and Kernel.apply/3 all let a command name become a call target.

Criterion (b), in the negative only: no streaming or back-pressure seam ([:Stream, :GenStage, :Flow, :Broadway]) in the bridge tree.

Runs every source-level mechanical sub-assertion over source_string and returns the COMPLETE SET of violations.

The closed denial vocabulary as wire strings.

Extracts the wire values from a native BridgeCommand enum block.

Extracts denial reason string literals from native (Swift/Kotlin) source.

The two native command-enum sources checked for bidirectional parity.

The native sources whose emitted denial reason strings are checked against the closed vocabulary. Enumerated, not globbed: a source that has moved must be noticed, not silently skipped.

The eight enumerated out-of-vocabulary native denial reason strings, each with its shipping sites, an individual justification, and the SEED-008 id (D-16, resolved as option-b with amendment).

Native enum cases exempt from orphan detection because they are outbound server -> shell pushes with no inbound bounded-bridge request seam.

The shipped command -> capability family map, read from Crosswake.Bridge.Registry.

Functions

assert_catalog_closed!(opts \\ [])

@spec assert_catalog_closed!(keyword()) :: :ok

Walks the real shipped sources and raises on the first violated criterion, with the six-step recipe for legitimately adding the next control.

Returns :ok when the catalog line holds.

Options — all defaulting to the real shipped values

assert_catalog_closed!() with no options is the merge-blocking gate, unchanged. See the moduledoc's "injection seam" section for why the options exist.

bounded_bridge_capability_ids()

@spec bounded_bridge_capability_ids() :: [String.t()]

The bounded-bridge capability families declared in Manifest.Builder's capability catalog — the attestation file that already exists (D-42).

bridge_sources(root \\ File.cwd!())

@spec bridge_sources(String.t()) :: [String.t()]

The lib/ sources the catalog line is enforced over.

root defaults to File.cwd!() — the shipped tree. It is parameterised only so the recipe-followability proof can point the same walk at a temp fixture tree.

check_attestation(commands, command_capability_map, catalog_capability_ids)

@spec check_attestation([String.t()], %{required(String.t()) => String.t() | nil}, [
  String.t()
]) ::
  :ok | {:violation, list()}

Criterion (a): every catalog entry maps to a shipped command and every shipped command maps to a catalog entry.

Rejecting only gaps would let a command ship with no owner, no rebuild cost, and no declared denial — an undeclared control wearing a declared one's badge. Rejecting only orphans would let a catalog entry claim a control that does not exist. Both directions or neither.

check_command_list_literal(source)

@spec check_command_list_literal(String.t()) :: :ok | {:violation, list()}

Criterion (d): the command vocabulary must be a compile-time literal.

A ~w sigil or a plain list of string literals is a literal. A list built with ++, a comprehension, an Enum call, or string interpolation is not — that is a runtime-constructed vocabulary, which is the plugin-catalog road under a different name. A source with no @commands attribute is clean; not every file declares a vocabulary.

check_denial_allowlist_liveness(root \\ File.cwd!())

@spec check_denial_allowlist_liveness(String.t()) :: :ok | {:violation, list()}

Asserts every allowlist entry is still emitted by at least one of its declared sites. An entry whose string has been fixed or deleted is rot: it makes the allowlist look larger than the real debt and quietly widens the gate.

check_native_denial_reasons(source)

@spec check_native_denial_reasons(String.t()) :: :ok | {:violation, list()}

Asserts every statically extractable native denial reason is either in the closed Crosswake.Shell.Denial vocabulary or on the eight-entry seeded allowlist. A NINTH out-of-vocabulary string is a violation.

check_native_enum_parity(native_source, commands)

@spec check_native_enum_parity(String.t(), [String.t()]) :: :ok | {:violation, list()}

Criterion (d): native command enum parity, asserted in BOTH directions.

A GAP is an Elixir command absent from the native enum. An ORPHAN is a native enum case with no Elixir command, excluding the enumerated outbound-only pushes. Checking one direction only would let a native shell ship a command the server has never heard of — which is the same hole as a dynamic registration seam, opened from the other end.

An unlocatable enum block is a violation, never a vacuous pass.

check_no_atom_minting(source)

@spec check_no_atom_minting(String.t()) :: :ok | {:violation, list()}

Criterion (d): no atom minting. String.to_atom/1 and List.to_atom/1 grow the atom table from wire input. String.to_existing_atom/1 is on the frozen allowlist — it cannot mint, only resolve.

check_no_dynamic_registration(source)

@spec check_no_dynamic_registration(String.t()) :: :ok | {:violation, list()}

Criterion (d): no dynamic-registration seam.

Any def/defp/defmacro whose name starts with register_ is a violation. Prefix match, not substring: registry_lookup/1 is fine.

check_no_external_sdk(source)

@spec check_no_external_sdk(String.t()) :: :ok | {:violation, list()}

Criterion (c): zero external SDK. An allowlist walk over every alias/import/require/use declaration; anything rooted outside [:Crosswake, :Phoenix, :Logger, :Jason, :Kernel] is an external SDK reaching into the bounded bridge.

check_no_runtime_apply(source)

@spec check_no_runtime_apply(String.t()) :: :ok | {:violation, list()}

Criterion (d): no runtime function application. apply/2, apply/3, and Kernel.apply/3 all let a command name become a call target.

check_no_streaming_seam(source)

@spec check_no_streaming_seam(String.t()) :: :ok | {:violation, list()}

Criterion (b), in the negative only: no streaming or back-pressure seam ([:Stream, :GenStage, :Flow, :Broadway]) in the bridge tree.

This proves no streaming seam EXISTS. It does not prove nobody calls a control in a loop — see the moduledoc's honest labelling.

check_source(source_string)

@spec check_source(String.t()) :: :ok | {:violation, list()}

Runs every source-level mechanical sub-assertion over source_string and returns the COMPLETE SET of violations.

A source violating five criteria reports all five. Short-circuiting on the first would make a five-violation control look like a one-violation control, and the fix-one-rerun loop is exactly how a structural gate degrades into a nuisance.

Returns :ok, or {:violation, [{criterion_atom, node_or_detail}]}. An empty or trivially small source is clean. An UNPARSEABLE source is a violation, not a pass — the guard fails closed.

closed_vocabulary()

@spec closed_vocabulary() :: [String.t()]

The closed denial vocabulary as wire strings.

extract_native_command_enum(source)

@spec extract_native_command_enum(String.t()) :: {:ok, [String.t()]} | :error

Extracts the wire values from a native BridgeCommand enum block.

Returns {:ok, wire_values} or :error. :error when the enum block cannot be located at all — carrying forward Phase 134's guard that a job not found is a FAILURE, not a pass. An extractor that silently returns [] for a renamed enum turns the parity check green at exactly the moment it should be red.

extract_native_denial_reasons(source)

@spec extract_native_denial_reasons(String.t()) :: [String.t()]

Extracts denial reason string literals from native (Swift/Kotlin) source.

Recognises three emission shapes, each anchored on a real call site rather than a bare regex over every literal in the file:

  1. The FIRST reason-shaped literal inside a balanced deny( / Denied( / unavailable( call — covers both the Swift labelled-argument form (reason: "x") and the Kotlin positional form.
  2. Every reason-shaped literal in a let/val/var reason = assignment expression — covers ternary-assigned reasons.
  3. Nothing at all when the reason argument is a VARIABLE. That is not a miss; it is the unbounded host-supplied seam, which is not statically bounded and is carried by SEED-008 (see the moduledoc's non-mechanical exclusion).

"Reason-shaped" means lowercase with at least one underscore — see the moduledoc's known extractor limitation.

native_command_enum_sources(root \\ File.cwd!())

@spec native_command_enum_sources(String.t()) :: [String.t()]

The two native command-enum sources checked for bidirectional parity.

native_denial_sources(root \\ File.cwd!())

@spec native_denial_sources(String.t()) :: [String.t()]

The native sources whose emitted denial reason strings are checked against the closed vocabulary. Enumerated, not globbed: a source that has moved must be noticed, not silently skipped.

out_of_vocabulary_denial_allowlist()

@spec out_of_vocabulary_denial_allowlist() :: [map()]

The eight enumerated out-of-vocabulary native denial reason strings, each with its shipping sites, an individual justification, and the SEED-008 id (D-16, resolved as option-b with amendment).

A NINTH string turns check_native_denial_reasons/1 red. Padding this list is the failure mode the individual justifications exist to make visible: adding an entry means writing down, in review, why the closed vocabulary could not answer.

outbound_only_native_commands()

@spec outbound_only_native_commands() :: [String.t()]

Native enum cases exempt from orphan detection because they are outbound server -> shell pushes with no inbound bounded-bridge request seam.

shipped_command_capability_map()

@spec shipped_command_capability_map() :: %{required(String.t()) => String.t() | nil}

The shipped command -> capability family map, read from Crosswake.Bridge.Registry.