Skip to content

fix(store): ADR 0114 sub-lever A — the claim-proc startup gate has never passed - #86

Merged
wshallwshall merged 2 commits into
mainfrom
claude/adr0114-claim-proc-gate
Jul 31, 2026
Merged

fix(store): ADR 0114 sub-lever A — the claim-proc startup gate has never passed#86
wshallwshall merged 2 commits into
mainfrom
claude/adr0114-claim-proc-gate

Conversation

@wshallwshall

@wshallwshall wshallwshall commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

The bug

OBJECT_DEFINITION() does not return a CREATE OR ALTER module verbatim. SQL Server deletes the OR and ALTER keyword tokens and keeps their separators, so a head submitted as CREATE OR ALTER PROCEDURE dbo.x comes back as CREATE + three spaces + PROCEDURE dbo.x — character delta exactly 7, everything after the head byte-identical.

The ADR 0114 startup gate hashed the submitted text. It therefore could never match, and sub-lever A has been inert in every deployment since it shippedfifo_claim_proc degraded to the ad-hoc batch on every open, silently, for the whole life of the feature.

Measured on SQL Server 2022 16.0.4255.1 and 2025 17.0.4055.5, compat 130/160/170, across five deploy paths (fresh CREATE, the OR ALTER re-apply, a plain batch, inside the shipped guarded EXEC(N'…'), and an out-of-band ALTER PROCEDURE — which the engine also rewrites, to a single-spaced CREATE PROCEDURE).

Live legs: 5 failed / 1 passed → 6 passed.

Why the tests didn't catch it

_gate_rows resolved its "SHIPPED" sentinel by calling ss._claim_proc_body(...) and handing that back as the fake OBJECT_DEFINITION answer — modelling the server as an identity function. Both sides of the gate's comparison were the same function of the same argument. Three mutually contradictory implementations (the bug, a correct fix, and an over-wide fix that blinds the gate to the whole signature) all scored 51/51 against it.

There was a second door: test_ac7_gate_tolerates_whitespace_differences bypassed the fixture default by passing cid_body= explicitly, with a string that still carried CREATE OR ALTER. Fixing only the default would have left the suite blind in exactly the same way.

Both are closed here.

The fix — shipped-side only

The gate now compares the deployed hash against a small set of code-controlled constants: the head forms a server may store for a module this build deployed (_CLAIM_PROC_STORED_HEADS — the measured rewritten form, plus verbatim for a hypothetical non-rewriting engine, which is simply the text we submitted).

_claim_proc_body() renders byte-identically, so _claim_proc_ddl, _SCHEMA, _schema_hash() and both golden body pins are untouched — no re-pin, and no forced DDL re-apply on any live database. Verified: the body hashes are still 9c685181… / 0eaf2de2….

Three design points worth review:

  • Keyed per proc, deliberately. A single flat accepted-set would take the cid body served under the dst name — reachable with no tampering intent via sp_rename, which does not rewrite sys.sql_modules.definition — and silently swap the lane predicate, claiming zero rows forever.
  • Head spellings this deploy path cannot emit keep failing the gate (CREATE PROC, differing case). Each is affirmative evidence of an out-of-band hand deploy, which is the AC-7 event. A two-sided canonicalization would launder exactly that signal.
  • Not a regex. A two-sided regex form was evaluated and rejected: its correctness rests on properties no finite assertion can pin. Mutation-scored 0/7 against this suite — dropping ^, adding MULTILINE, dropping count=1, widening to \w+, eating the object name, dropping IGNORECASE all survive every test, and one of them makes the gate accept a body deployed under the other lane family's name. The shipped-side form is pinned by one set-equality assertion.

Two more fixes, in the same PR because the gate fix makes them reachable

The pin leak — this is why D1 must not land alone

The proc CALL pins 9 parameter descriptors on the pooled cursor (descriptor[0] = SQL_DOUBLE for @now FLOAT). setinputsizes is persistent cursor state, and at OUTBOUND the same cursor then runs the H2 delivery probe, binding an NVARCHAR id against the stale SQL_DOUBLE descriptor → client 22018 → rollback → outbound delivery collapse (measured previously at 1122 errors == 1122 failed deliveries). Pins are now cleared the moment the CALL's result is drained.

main today ships the lever but not the fixes. A prior cherry-pick landed the ADR 0114 flag architecture while reverting this and the gate fix. Flags default OFF so it is inert — but anyone setting fifo_claim_proc=true on current main gets the dead gate and the collapse. Fixing the gate alone would arm the collapse for the first operator who flips the flag.

CI could never have caught it. Every test in test_adr0114_claim_proc_live.py claims at Stage.INGRESS; the only OUTBOUND leg that exists anywhere is the one this PR adds. So landing the gate fix and wiring the file — the obvious plan — would have turned that file green in CI while the collapse stayed live in production, with nothing downstream able to see it.

Measured on pyodbc 5.3.0 / ODBC Driver 18: a zero-parameter execute tolerates surplus descriptors, so the shielded SET LOCK_TIMEOUT -1; reset in the finally guard is unaffected and the exposure is exactly the H2 bind chain. That measurement carries its own positive control — the same run reproduced [22018] Invalid character value for cast specification (0) on the parameterized bind, where (0) is the SQL_DOUBLE @now descriptor. A recorded negative with a demonstrated positive control, so nobody has to re-open the question.

AC-11 no-match parity on the ad-hoc batch

Only the two flagged branches honoured it. The batch bound the raw lane list into (VALUES (?),…) feeding a DECLARE @heads TABLE (lane NVARCHAR(256) NOT NULL — SQL Server evaluates that narrowing conversion on the outer constant scan before the CROSS APPLY filters it, so with ANSI_WARNINGS ON an oversized lane raises 2628 even when zero rows would match. 2628 is not 1222, so it is not translated to EMPTY-all: it rolls back and re-raises to the dispatcher.

This was not deferrable — fixing the gate made the parity test reach its batch arm and it failed live. The skip is hoisted ahead of the dispatch-path split so all three branches agree. The clamp still runs before the skip, preserving the tested contract (test_prepared_lane_encoding_shares_the_proc_rules pins 499, not 500).

Verification

check result
Full offline suite 9490 passed, 825 skipped, 0 failed
Live legs, real SQL Server 2022 CU25 6 passed (was 5 failed / 1 passed)
Mutation — bug / flattened key / over-wide / pin-clear no-op 10 / 3 / 8 / 1 failed
Mutation — correct implementation 64 passed
ruff · ruff-format · mypy · ledger gate · bandit · secrets pass
Golden body pins unmoved

The mutation row is the point: the previous suite could not distinguish a working gate from a broken one. Every wrong implementation is now caught by a named test.

Docs

Three docstrings asserted the false premise ("OBJECT_DEFINITION() preserves the definition text as executed"). The operator-facing degraded reason prescribed DELETE FROM schema_meta, which could not work — the re-apply submits the same text, the engine rewrites it identically, and the hash mismatches again, forever. Both corrected, plus an ADR 0114 amendment.

AC-7b added. AC-7 is one-directional: it required the gate to degrade on a mismatch, and nothing anywhere required a correctly deployed proc to pass. The defect therefore satisfied AC-7 literally while leaving the lever inert — AC review could not have caught this. Any future gate-shaped AC needs both directions.

CI

Adopts the dormant ci-suite-wiring commit (cherry-picked with -x), which wires 16 module-gated suites that ran in no workflow step — 84 tests, dark — into four new steps, and adds adr0114 to the path-filter alternation so a test-only follow-up still fires the leg. Includes test_serverdb_ci_coverage.py, which fails if a module-gated suite is added without wiring.

Performance

Latency, not capacity. Previously measured at ~65% claim_mean reduction (~9 ms/claim). Sustained throughput is unchanged — the characterisation of record is "latency-proven, throughput-null on N=4", sourced to the at-knee soak (336.3 vs 337.1, arm-identical) and TO-ENGINE-072, not to the @150/180 re-gate, whose rungs were sub-knee and explicitly declined to project. No throughput claim is made and no published capacity figure changes.

Not in this PR — recommended follow-ups, not yet filed

Deliberately out of scope here. Both need a BACKLOG number, and I have not allocated one: the allocator mutates a shared ledger, several sessions are live, and number reuse has corrupted that ledger before. Flagging them for the owner to file (or to tell me to) rather than allocating speculatively mid-review.

  1. The AC-7 degraded gauge does not exist. claim_proc_effective and claim_proc_degraded_reason reach no API, /stats, /metrics or console surface — grep returns only store/sqlserver.py and tests. ADR 0114:401 describes the gate as setting a degraded gauge; it does not. The sole operator signal is one WARNING line at open. That is a real part of why this defect ran unnoticed for the life of the feature, and it is not fixed here.

  2. A missing VIEW DEFINITION grant is misdiagnosed as a missing proc. A principal holding only EXECUTE sees OBJECT_ID non-NULL but OBJECT_DEFINITION NULL. The gate treats a NULL body as missing and reports "guarded DDL skipped — CREATE PROCEDURE / ALTER-on-schema denied", sending the operator to entirely the wrong control; the cure is GRANT VIEW DEFINITION. WITH ENCRYPTION produces the same NULL and the same misdiagnosis. This is not hypothetical for this codebase — the sub-lever B comment at sqlserver.py:904-905 explicitly designs for a fleet whose principal can never hold CREATE PROCEDURE, which is exactly the posture that hits it. Fix: distinguish OBJECT_ID IS NULL from OBJECT_ID NOT NULL AND OBJECT_DEFINITION IS NULL and name the right grant. I left the probe SQL unchanged here so the gate's stubbed-_fetchone contract stays pinned.

Also deferred: the AC-11 lane-skip ordering question (the clamp currently runs before the skip, so an unmatchable lane still consumes a chunk slot — changing it would serve more lanes per call but alters a tested contract), and the orphaned vaultall/vault ref namespace, which holds ADR 0116, ADR 0117, two features and 22 BACKLOG items invisible to any normal git query. A separate session is surveying that.

🤖 Generated with Claude Code

@wshallwshall
wshallwshall force-pushed the claude/adr0114-claim-proc-gate branch from e49d720 to 787f21b Compare July 30, 2026 22:49
wshallwshall and others added 2 commits July 30, 2026 18:16
…re, and pin the wiring

Sixteen test modules gate themselves at import on MEFOR_TEST_SQLSERVER /
MEFOR_TEST_POSTGRES and are named by no workflow step. A module-level pytestmark skipif
yields zero collected tests when the gate is unset, so these did not pass, did not fail,
and did not appear as skips on any trigger — not a PR, not push-to-main, not the nightly
cron. 84 tests, dark. Among them: engine-shard crash recovery on both server backends,
the PostgreSQL failover suite (its SQL Server twin has run for months), the ADR 0048/0049
DR seed gate that must refuse to activate a standby onto a non-empty store, and the
.mfbak backup runner against a real server DB.

Wires all sixteen into four new steps — two on sqlserver-store (engine-shard and
statement-dispatch; DR seed-gate and backup) and two on postgres-store (failover and
shard recovery; DR seed-gate and backup) — reusing each leg's existing env block and, on
SQL Server, the pyodbc#1459 native-crash retry wrapper.

Extends the `serverdb` change-detection alternation to admit them, plus four files the
legs already ran that it never matched (connscale_postgres, load_failover_{postgres,
sqlserver}, load_runner). Editing any of them now pulls the leg that proves it; before,
they were reachable only by the nightly cron. The regex's own comment already required
this ("Keep this in sync with those steps") — nothing enforced it.

tests/test_serverdb_ci_coverage.py is that enforcement. It asserts (a) every module-gated
suite is named by a workflow step and (b) every file a gated step runs is matched by the
alternation. Detection is AST-based and deliberately narrow: only a module-level gate
counts, because that is the shape that yields zero executed tests. A per-test decorator
leaves the SQLite cases running, which is a weaker and separately-tracked gap — keeping
the assertion sharp means a failure always means "this file runs nowhere" and the
allow-list can stay empty.

Verified: both assertions go red under mutation (unwire one file; drop one alternation
entry) and green on restore. All 16 suites collect — 84 tests, no import rot.

CI is the remaining verifier: these suites have not executed in a long time, so some may
legitimately fail against the real backends. That is the point of running them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 8e24dbf)
…ver passed

OBJECT_DEFINITION() does not return a CREATE OR ALTER module verbatim: SQL Server
deletes the OR and ALTER keyword TOKENS and keeps their separators, so a head
submitted as `CREATE OR ALTER PROCEDURE dbo.x` comes back as `CREATE` + three
spaces + `PROCEDURE dbo.x` (delta exactly 7; everything after the head is
byte-identical). The gate hashed the SUBMITTED text, so it could never match.

Sub-lever A has therefore been INERT in every deployment since it shipped — the
gate degraded to the ad-hoc batch on every open, and no test noticed because the
offline fixture fed the submitted body back as the "deployed" body, modelling the
server as an identity function. Both sides of the comparison were the same
function of the same argument.

Measured on SQL Server 2022 16.0.4255.1 and 2025 17.0.4055.5, compat 130/160/170,
across five deploy paths. Live legs go 5-failed/1-passed -> 6 passed.

THE FIX IS SHIPPED-SIDE ONLY. The gate compares the deployed hash against a small
set of code-controlled constants — the head forms a server may store for a module
THIS build deployed (_CLAIM_PROC_STORED_HEADS: the measured `rewritten` form, plus
`verbatim` for a hypothetical non-rewriting engine). _claim_proc_body() renders
byte-identically, so _claim_proc_ddl, _SCHEMA, _schema_hash() and both golden body
pins are untouched: no re-pin, no forced DDL re-apply on any live database.

The expected map is keyed PER PROC. A single flat accepted-set would take the cid
body served under the dst name — reachable with no tampering intent via sp_rename,
which does not rewrite sys.sql_modules.definition — and silently swap the lane
predicate, claiming zero rows forever.

Head spellings this deploy path cannot emit (CREATE PROC, differing case) keep
failing the gate: each is affirmative evidence of an out-of-band hand deploy,
which is the AC-7 event. A two-sided canonicalization would launder exactly that
signal, and its correctness rests on regex minutiae no finite assertion can pin
(mutation-scored 0/7 against this suite; the shipped-side form scores 3/3).

ALSO IN THIS COMMIT, because the gate fix makes them reachable:

* The proc CALL's 9 parameter pins are PERSISTENT cursor state and were never
  cleared. At OUTBOUND the same pooled cursor runs the H2 delivery probe, binding
  an NVARCHAR id against the stale SQL_DOUBLE descriptor for @now FLOAT -> client
  22018 -> rollback -> outbound delivery collapse. Fixing the gate alone would arm
  this for the first operator who sets fifo_claim_proc=true. Cleared the moment
  the CALL's result is drained. Verified on pyodbc 5.3.0 / ODBC Driver 18 that a
  ZERO-parameter execute tolerates surplus descriptors, so the shielded
  `SET LOCK_TIMEOUT -1;` reset in the finally-guard is unaffected and the exposure
  is exactly the H2 bind chain.

* AC-11 no-match parity was honoured only on the two flagged branches. The ad-hoc
  batch bound the raw lane list into (VALUES (?),...) feeding a
  `DECLARE @heads TABLE (lane NVARCHAR(256) NOT NULL` — SQL Server evaluates that
  narrowing conversion on the outer constant scan before the CROSS APPLY filters
  it, so with ANSI_WARNINGS ON an oversized lane raises 2628 even when zero rows
  would match. 2628 is not 1222, so it is not translated to EMPTY-all: it rolls
  back and re-raises. The skip is hoisted ahead of the dispatch-path split so all
  three branches agree. Clamp still runs before the skip (the tested contract).

TESTS — the fix's only evidence. The offline suite could not previously tell a
working gate from a broken one; three contradictory implementations all scored
51/51. Both fixture doors are closed (the `_gate_rows` default AND the whitespace
test that passed cid_body= explicitly), the "deployed" body now comes from an
INDEPENDENT model of the server rewrite with a liveness receipt, and new tests pin
the accepted set by set-equality, reject cross-proc substitution, reject
un-emittable head spellings, and require the anchor break to degrade diagnosably.
The live tamper leg gains the in-test positive control it lacked — under the old
defect the gate rejected everything, so its degrade assertions were vacuous.

Mutation check: bug 10 failed, flattened-key 3 failed, over-wide 8 failed,
pin-clear no-op 1 failed; correct implementation 64 passed.

DOCS — three docstrings asserted the false premise ("OBJECT_DEFINITION() preserves
the definition text as executed"). The operator-facing degraded reason prescribed
`DELETE FROM schema_meta`, which could not work: the re-apply submits the same
text and mismatches again, forever. ADR 0114 amended; AC-7b added, because AC-7 is
one-directional and was LITERALLY SATISFIED by the defect — nothing anywhere
required a correctly deployed proc to pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@wshallwshall
wshallwshall force-pushed the claude/adr0114-claim-proc-gate branch from 787f21b to 9541d4b Compare July 30, 2026 23:17
@wshallwshall
wshallwshall merged commit 6c9924b into main Jul 31, 2026
36 checks passed
@wshallwshall
wshallwshall deleted the claude/adr0114-claim-proc-gate branch July 31, 2026 00:06
wshallwshall added a commit that referenced this pull request Jul 31, 2026
… the console

AC-7 requires "a WARNING naming the reason + degraded gauge", and §4's
compensating-control story assumes an operator can SEE the degraded state. Nobody
could: claim_proc_effective / claim_proc_degraded_reason were read by the store's
own tests and nothing else — no /stats, no /status, no /metrics, no console. The
whole operator signal was one WARNING at open(), in a log nobody was watching,
that until PR #86 named the wrong cause. That is a load-bearing part of why the
lever could sit inert in every deployment for its entire life.

Surfaces the store's claim_proc_status() on:

  * GET /status  -> claim_proc: effective, the human-readable degraded_reason,
                    and the matched head_forms
  * GET /metrics -> messagefoundry_store_claim_proc_effective and
                    messagefoundry_store_claim_proc_head_verbatim
  * /ui/status   -> store-panel rows

Three shape decisions. The field/series are ABSENT, not 0/false, when the lever
was never requested: a constant 0 on every SQLite fleet is unalertable noise, and
absence keeps "not requested" distinct from "requested and degraded". No reason
LABEL in the exposition — the reason is free text embedding a proc name and, on
the probe-failure arm, an exception string, so a label would be unbounded
cardinality and a breach of the exporter's strict {connection, destination,
status, version, le} allowlist; the string goes to /status and the console. And it
deliberately does NOT feed the console's engine-health heart: claims keep flowing
on the batch, so a degrade is a lever not paying off, not an unwell store, and
making the nav cry wolf would devalue the signal that means it is.

head_forms is surfaced for the same reason it is logged: a fleet reporting
"verbatim" is a live counterexample to _CLAIM_PROC_STORED_HEADS's compatibility
assumption, and was previously visible only at INFO.

Observability only — the gate's accept/degrade logic is untouched. The new tests
assert the rendered output of each surface, not the existence of a property.

Rendering a new SystemStatus field on the console is a seam change, so
ENGINE_UI_SEAM and the console's SUPPORTED_ENGINE_SEAMS both move to 16 and the
golden snapshot is refreshed. A separate seam rather than a correction to the
unreleased v15: v15 is a SecurityPosture change, and folding an unrelated DTO into
it would make that note describe a field set it does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wshallwshall added a commit that referenced this pull request Jul 31, 2026
… the console

AC-7 requires "a WARNING naming the reason + degraded gauge", and §4's
compensating-control story assumes an operator can SEE the degraded state. Nobody
could: claim_proc_effective / claim_proc_degraded_reason were read by the store's
own tests and nothing else — no /stats, no /status, no /metrics, no console. The
whole operator signal was one WARNING at open(), in a log nobody was watching,
that until PR #86 named the wrong cause. That is a load-bearing part of why the
lever could sit inert in every deployment for its entire life.

Surfaces the store's claim_proc_status() on:

  * GET /status  -> claim_proc: effective, the human-readable degraded_reason,
                    and the matched head_forms
  * GET /metrics -> messagefoundry_store_claim_proc_effective and
                    messagefoundry_store_claim_proc_head_verbatim
  * /ui/status   -> store-panel rows

Three shape decisions. The field/series are ABSENT, not 0/false, when the lever
was never requested: a constant 0 on every SQLite fleet is unalertable noise, and
absence keeps "not requested" distinct from "requested and degraded". No reason
LABEL in the exposition — the reason is free text embedding a proc name and, on
the probe-failure arm, an exception string, so a label would be unbounded
cardinality and a breach of the exporter's strict {connection, destination,
status, version, le} allowlist; the string goes to /status and the console. And it
deliberately does NOT feed the console's engine-health heart: claims keep flowing
on the batch, so a degrade is a lever not paying off, not an unwell store, and
making the nav cry wolf would devalue the signal that means it is.

head_forms is surfaced for the same reason it is logged: a fleet reporting
"verbatim" is a live counterexample to _CLAIM_PROC_STORED_HEADS's compatibility
assumption, and was previously visible only at INFO.

Observability only — the gate's accept/degrade logic is untouched. The new tests
assert the rendered output of each surface, not the existence of a property.

Rendering a new SystemStatus field on the console is a seam change, so
ENGINE_UI_SEAM and the console's SUPPORTED_ENGINE_SEAMS both move to 16 and the
golden snapshot is refreshed. A separate seam rather than a correction to the
unreleased v15: v15 is a SecurityPosture change, and folding an unrelated DTO into
it would make that note describe a field set it does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wshallwshall added a commit that referenced this pull request Jul 31, 2026
… the console

AC-7 requires "a WARNING naming the reason + degraded gauge", and §4's
compensating-control story assumes an operator can SEE the degraded state. Nobody
could: claim_proc_effective / claim_proc_degraded_reason were read by the store's
own tests and nothing else — no /stats, no /status, no /metrics, no console. The
whole operator signal was one WARNING at open(), in a log nobody was watching,
that until PR #86 named the wrong cause. That is a load-bearing part of why the
lever could sit inert in every deployment for its entire life.

Surfaces the store's claim_proc_status() on:

  * GET /status  -> claim_proc: effective, the human-readable degraded_reason,
                    and the matched head_forms
  * GET /metrics -> messagefoundry_store_claim_proc_effective and
                    messagefoundry_store_claim_proc_head_verbatim
  * /ui/status   -> store-panel rows

Three shape decisions. The field/series are ABSENT, not 0/false, when the lever
was never requested: a constant 0 on every SQLite fleet is unalertable noise, and
absence keeps "not requested" distinct from "requested and degraded". No reason
LABEL in the exposition — the reason is free text embedding a proc name and, on
the probe-failure arm, an exception string, so a label would be unbounded
cardinality and a breach of the exporter's strict {connection, destination,
status, version, le} allowlist; the string goes to /status and the console. And it
deliberately does NOT feed the console's engine-health heart: claims keep flowing
on the batch, so a degrade is a lever not paying off, not an unwell store, and
making the nav cry wolf would devalue the signal that means it is.

head_forms is surfaced for the same reason it is logged: a fleet reporting
"verbatim" is a live counterexample to _CLAIM_PROC_STORED_HEADS's compatibility
assumption, and was previously visible only at INFO.

Observability only — the gate's accept/degrade logic is untouched. The new tests
assert the rendered output of each surface, not the existence of a property.

Rendering a new SystemStatus field on the console is a seam change, so
ENGINE_UI_SEAM and the console's SUPPORTED_ENGINE_SEAMS both move to 16 and the
golden snapshot is refreshed. A separate seam rather than a correction to the
unreleased v15: v15 is a SecurityPosture change, and folding an unrelated DTO into
it would make that note describe a field set it does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
wshallwshall added a commit that referenced this pull request Jul 31, 2026
…diagnosed as a missing proc (#97)

* fix(store): a missing VIEW DEFINITION grant read as a missing claim proc

ADR 0114 §4 has always specified the startup gate's probe (a) as "OBJECT_ID of
both procs", but the implementation folded (a) into (b) and inferred absence from
a NULL OBJECT_DEFINITION.

MEASURED: a principal holding only EXECUTE on the proc gets a non-NULL OBJECT_ID
and a NULL OBJECT_DEFINITION, and the compat probe still passes. So a deployed,
working, correct procedure was reported as *missing* and the operator was sent to
grant CREATE PROCEDURE — neither the cause nor the cure. WITH ENCRYPTION produces
the identical NULL. This is not hypothetical: the sub-lever B design note in the
same module explicitly serves a fleet whose principal can never hold CREATE
PROCEDURE (DBA-provisioned procs + a least-privilege app principal), which is
exactly the posture that hits it.

The probe now returns OBJECT_ID beside the definition and the two conditions get
separate reasons; the actual cure, GRANT VIEW DEFINITION, is named. Both still
DEGRADE — the gate hashes the body and cannot pass on one it cannot read — so no
accept/reject behaviour changed, only the diagnosis.

The offline stub pins the probe SQL by exact match (a typo'd probe must fail
loudly rather than silently match); that pin moved with the SQL and stayed exact,
and now also asserts both placeholders bind the same object. New offline legs
cover both arms and fail without the fix. A live leg pins the premise no stub can
show — that OBJECT_ID and OBJECT_DEFINITION genuinely disagree on a real server —
using WITH ENCRYPTION, which needs no security principal; the permission half
stays deferred with AC-10's other permission scenarios.

Also adds the store-side half of AC-7's degraded gauge: a claim_proc_status()
accessor on the store protocol (None on every backend without the lever and when
the flag is off, so "not requested" stays distinguishable from "requested and
degraded"). The surfaces that read it land next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(ops): publish ADR 0114's degraded gauge on /status, /metrics and the console

AC-7 requires "a WARNING naming the reason + degraded gauge", and §4's
compensating-control story assumes an operator can SEE the degraded state. Nobody
could: claim_proc_effective / claim_proc_degraded_reason were read by the store's
own tests and nothing else — no /stats, no /status, no /metrics, no console. The
whole operator signal was one WARNING at open(), in a log nobody was watching,
that until PR #86 named the wrong cause. That is a load-bearing part of why the
lever could sit inert in every deployment for its entire life.

Surfaces the store's claim_proc_status() on:

  * GET /status  -> claim_proc: effective, the human-readable degraded_reason,
                    and the matched head_forms
  * GET /metrics -> messagefoundry_store_claim_proc_effective and
                    messagefoundry_store_claim_proc_head_verbatim
  * /ui/status   -> store-panel rows

Three shape decisions. The field/series are ABSENT, not 0/false, when the lever
was never requested: a constant 0 on every SQLite fleet is unalertable noise, and
absence keeps "not requested" distinct from "requested and degraded". No reason
LABEL in the exposition — the reason is free text embedding a proc name and, on
the probe-failure arm, an exception string, so a label would be unbounded
cardinality and a breach of the exporter's strict {connection, destination,
status, version, le} allowlist; the string goes to /status and the console. And it
deliberately does NOT feed the console's engine-health heart: claims keep flowing
on the batch, so a degrade is a lever not paying off, not an unwell store, and
making the nav cry wolf would devalue the signal that means it is.

head_forms is surfaced for the same reason it is logged: a fleet reporting
"verbatim" is a live counterexample to _CLAIM_PROC_STORED_HEADS's compatibility
assumption, and was previously visible only at INFO.

Observability only — the gate's accept/degrade logic is untouched. The new tests
assert the rendered output of each surface, not the existence of a property.

Rendering a new SystemStatus field on the console is a seam change, so
ENGINE_UI_SEAM and the console's SUPPORTED_ENGINE_SEAMS both move to 16 and the
golden snapshot is refreshed. A separate seam rather than a correction to the
unreleased v15: v15 is a SecurityPosture change, and folding an unrelated DTO into
it would make that note describe a field set it does not cover.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs(adr-0114): record the degraded gauge and the missing-vs-unreadable split

Amends §4 and the acceptance criteria for the two follow-ups the 2026-07-30
amendment deliberately held out of the bug fix.

"Sets a degraded gauge" stops being aspirational: §4 now names the three surfaces
that publish it, and records the three shape decisions (absent rather than 0 when
the lever is not requested; no free-text reason label in the exposition; it does
not feed the console's engine-health heart) so they are not re-litigated.

AC-7 gains the unreadable-definition condition. New AC-7c requires the gauge to be
a surface an operator can READ, not merely an attribute — AC-7 as written required
a gauge and nothing required anyone to be able to see it, which is the same
one-directional gap the 2026-07-30 amendment found in the gate itself.

Also corrects the record on probe (a): the ADR always specified an OBJECT_ID
probe, and the implementation had folded it into the OBJECT_DEFINITION read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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