Standalone answer test platform for the ACR Context Fabric: ask a question, inspect both the intelligence and its presentation.
Tracked as CHAOS-3738 (repo scaffolded under CHAOS-3803).
The Workbench lets an authorized tester ask Context Fabric the real product
questions and inspect the answer and its presentation, without depending on
the legacy Ask Dev path or its UX. It is platform/test scoped until the Context
Fabric beta gate passes, and is explicitly separate from the Ask Dev window
and /dev.
It is a read-only consumer. It authors no facts, metrics, health states, drivers, evidence, scope, or authorization. Its only write-shaped interaction is asking a question.
It calls the real ACR investigation API. It does not consume mock investigation results — fixtures exist only inside unit tests, and a lint rule fails the build if product code imports one.
The manifest, component library, and interaction model are meant to be promoted
into Ask Dev later, so portability is a standing constraint: React + TypeScript,
minimal dependencies, and the same Next.js version dev-health-web runs.
One immutable result, three views (CHAOS-3738):
- Canonical result inspector — the complete ACR result with structure unhidden: resolved subjects and bound receipts, analytical goal and scope, cohort membership with inclusion/exclusion rationale, relationship and evidence paths, canonical facts, coverage and limitations, every version stamp, and the raw contract payload.
- Deterministic answer view — the reference answer and the fallback, rendered by a native component set with no model involved.
- OpenUI enrichment view — not yet built (M3). It will be driven by a Dev Health-owned, reference-only presentation manifest and a closed component library, validated in full before rendering and failing closed to the deterministic view.
The raw and deterministic views stay available beside the enriched one, so presentation can never mask an answer-quality failure.
Rendered today:
- the answer —
deterministic_answer,direct_judgment,current_state, strongest pressures, and drivers; - coverage — every source with its contract state, including
pruned,unauthorized, andno_data, pluspartialanddegraded_reasons; - limitations and warnings;
- subject resolution — committed subjects, and the candidates the service could not choose between, with its own clarification prompt;
- evidence references — the
evidence_ref_idsverbatim, per driver, per finding, per candidate, and for the result as a whole; - provenance — the full
versionsblock; - claimed-fact tables/charts (CHAOS-4355, CHAOS-4347) — any claimed fact
carrying a renderable
rowstable gets its own panel stacked under the answer text: a plain table by default, or a chart (line for a time axis, bar for an ordinal one, small multiples — one mini-chart per numeric column — when there is more than one) when the rows have a usable axis plus at least one numeric column (src/lib/fact-rows.ts). The caption shows the fact's subject, row count, and the siblingrollup_basisclaim when the producer emits one. - cohort ranking (CHAOS-4449, acr CHAOS-4398 PR3/PR3b) — when the question
asked for one (
interpretation.shapeisexplicit_cohortordiscovered_cohort) and the result carries a cohort whose members acr actually ranked,CohortRankingPanelrenders one row per ranked member inattention_rankorder: rank, subject, score (an em dash when there is none — never a blank or a zero),outcome,data_completeness, the row-level window, and the member's two strongest drivers.src/lib/cohort-ranking.tsis a re-expression of acr's own reference rendering (internal/contextfabric/answerprojection/ranking_table.go) and re-derives nothing. Four rules hold it. It is conditional on intent, never default — the pinned canonical example issingle_subjectand still carries a ranked team cohort, so carrying the data is not on its own a reason to render a rich view (check 10); nothing is lost when the gate closes, since the raw cohort stays in the canonical result inspector. A score never appears without the drivers explaining it, and a score the contract accepts but nothing explains is withheld rather than shown — this view fails closed (check 8, andAGENTS.md). No ranked member at all renders nothing, not an empty table, because "ranking never ran" is a different claim from "nothing qualified". And members acr did not rank are named under the table rather than silently dropped as acr's own table drops them, with the cohort-levelcomplete/truncatedflags surfaced so a partial census never reads as an exhaustive one. The narrated§5ajudgments behind the ranking are ordinary resultdrivers, rendered byAnswerPanelwith their standing, epistemic status (inferred, never presented as an observation), affected subjects, and the claimed facts they cite.
pnpm install
pnpm dev # http://127.0.0.1:3000The Workbench needs a configured server hop before it can answer. Without one it
still runs and says so — it has no mock path, so an unconfigured server reports
workbench_misconfigured rather than inventing an answer.
| Variable | Required | Purpose |
|---|---|---|
ACR_API_ORIGIN |
yes | ACR base URL, e.g. http://127.0.0.1:18080 |
ACR_ORG_ID |
yes | Organization to investigate as |
ACR_WEB_ASSERTION_KEY_FILE |
yes | Path to the Ed25519 signing key |
ACR_REPOSITORY_SCOPES |
yes | Comma-separated owner/name slugs |
ACR_WEB_ASSERTION_ISSUER |
no | Default dev-health-web |
ACR_WEB_ASSERTION_AUDIENCE |
no | Default dev-health-acr |
ACR_WEB_ASSERTION_KID |
no | Default acr-dev-web |
ACR_SUBJECT |
no | Default context-fabric-workbench |
ACR_TIMEOUT_MS |
no | Default 120000 |
No .env is committed and no endpoint is hardcoded. The signing key is
referenced by path and never enters this repo. Nothing is NEXT_PUBLIC_*,
so no ACR value can reach the browser bundle.
Both ACR credentials are server-to-server by construction: bearerAuth is a
client secret, and webAssertionAuth requires signing each request with an
Ed25519 private key, bound to the exact method, path, and body digest, with
a 30-second lifetime. A browser cannot hold either. src/app/api/investigations
is the hop that signs and forwards; it mirrors dev-health-web's server-only
ACR client so the code ports cleanly when this surface migrates.
ci/run_checks.sh is the single entry point. CI runs the same tiers a developer
runs locally, so a green local run means a green CI run.
bash ci/run_checks.sh ci # everything, in CI order
bash ci/run_checks.sh format # prettier --check
bash ci/run_checks.sh contracts # exact-diff contract regeneration guard
bash ci/run_checks.sh lint
bash ci/run_checks.sh typecheck
bash ci/run_checks.sh unit
bash ci/run_checks.sh build
bash ci/run_checks.sh e2e # Playwright smoke over the BUILT artifactMost of the e2e suite (tests/workbench.smoke.spec.ts, and the shell/
honest-failure specs in tests/chat.spec.ts) runs with no ACR
configuration on purpose. Neither surface has a mock path, so the only honest
thing either can do unconfigured is say so — and proving that a failure
presents as a failure, never as a thin answer, is exactly what those specs are
for.
The chat surface's clarification-chip positive/negative controls
(tests/chat.spec.ts's "clarification chips" group) are the one exception:
they run against a SECOND, ACR-configured next start instance, pointed at
tests/support/fake-acr-server.mjs — a real, wire-level HTTP server standing
in for ACR (not a fetch/route mock; see that file's own header for the full
"why," including why a real ACR checkout can't be used here today). Both
instances are started by playwright.config.ts's webServer array; only that
one spec group opts into the configured one.
Workflows: tests.yml (the gates above), codeql-analysis.yml, and
security-scan.yml (Gitleaks + pnpm audit). Gitleaks is a hard gate here —
this repo starts with clean history, so an allowed-to-fail secret scan would be
a dead guard. It needs the organization's GITLEAKS_LICENSE secret to be
visible to this repository.
The ACR contract surface is copied, not fetched, from a pinned commit of
full-chaos/dev-health-acr. The copies and everything derived from them live
under src/contracts/ and are never hand-edited — scripts/sync-acr-contracts.mjs
is their only author.
src/contracts/
schemas/ exact copies of the pinned JSON Schemas
examples/ exact copies of the pinned canonical examples
generated/ TypeScript types compiled from the copies
manifest.json the pinned commit + a sha256 per copied file
pnpm acr:contracts:check recomputes every artifact and fails on the first byte
of drift. It needs no acr checkout: it verifies the copies against the manifest
digests and regenerates the types from them. That catches a hand-edited copy, a
hand-edited generated type, and a stale type after a pin bump.
Import the types through src/lib/contracts.ts, never from
src/contracts/generated/ directly. The generated identifiers come from the
schemas' title fields and change with the contracts; that file is the one
place a rename has to be absorbed.
- Park a clean
dev-health-acrworktree on the new commit (git -C <acr> status --porcelainmust be empty — the sync script refuses a dirty or differently-parked tree). - Set
SOURCE_COMMITinscripts/sync-acr-contracts.mjsto the full 40-character SHA. - Regenerate and verify against the source:
pnpm acr:contracts:generate --source /path/to/acr pnpm acr:contracts:check --source /path/to/acr
- Run the gates.
bash ci/run_checks.sh ci. - Read the diff of
src/contracts/. A changed enum is a UI change: the tone maps insrc/lib/presentation.tsare exhaustive over the closed vocabularies andsrc/lib/presentation.test.tsreads those enums straight out of the pinned schema, so a new state fails the suite instead of rendering blank.
Currently pinned: 9e2bbede5447843ad35eb2083c9c98465fb767bd (acr main tip
#382, CHAOS-4825 — anchor the published JSON Schemas to the Go wire structs;
contains #383, CHAOS-4831 — the answer-reuse evidence-containment degrade
fix, which merged first onto the same tip). ONE file in the CONSUMED surface
changed between the prior pin and this one — verified per-file directly
against acr's own history (git diff 9b2069de9495fca61433daebce65f773818281a5 9e2bbede5447843ad35eb2083c9c98465fb767bd -- contracts/jsonschema/v1/<file>
run once per consumed path):
context_fabric_common.v1.schema.json, two additive changes:- (#383)
CoverageDetail.code's closed enum gains a 12th value,reuse_auxiliary_refs_stripped. Answer reuse never hit on the live org because its evidence-containment recheck refused on ANY missing auxiliary (non-cited) ref; the fix now strips unverifiable auxiliary refs and serves a narrowed answer instead of refusing outright, disclosing the narrowing with this code and a requiredcount. Without this bump a degraded reuse answer fails CLOSED here withacr_contract_violationand reads as a rig failure, not a pin gap — the failure mode this ticket (CHAOS-4836) exists to close, proven red-then-green insrc/lib/acr/validate.test.ts. - (#382)
$defs.SourceObservationgains two optional properties,labelandstate_label(maxLength: 160each), matching the INLINE copy of the same shape atCoverage.properties.sources.items, which already carried both — this producer has emitted them since the CHAOS-4690 display-label work.SourceObservationhas zero$refs anywhere in acr (canonical schemas, embedded MCP copies, or the OpenAPI document), so nothing validates through the NAMED$defand this is a byte change to a vendored artifact, not a behavior change. Ridden along in this same bump per the orchestrator's ruling rather than forcing a second re-vendor.
- (#383)
context_fabric_investigation_request.v1.schema.json,
context_fabric_investigation_result.v1.schema.json, and error.v1.schema.json
are byte-identical to the prior pin, as are all four pinned examples.
Previous pin 9b2069de9495fca61433daebce65f773818281a5 (acr main tip
#369, CHAOS-4682 — §5.1 P2 dual-read cutover). A CanonicalFact can carry
BOTH a legacy breakdown/ranking table and a genuine time_series table at
once (a project's per-team breakdown alongside its daily series). The
pre-existing table/rows pair always serves the LEGACY field (CHAOS-4645's
ruling, unchanged by this pin), so a dual-table fact's time series existed at
the producer, validated, and never reached the wire. This pin adds the
additive time_series_table/time_series_rows pair so the workbench can
finally read it. TWO files in the CONSUMED surface changed between the prior
pin and this one — verified per-file directly against acr's own history
(git diff dbde584b41ddc6b89392344020b23cac7233559e 9b2069de9495fca61433daebce65f773818281a5 -- contracts/jsonschema/v1/<file>
run once per consumed path):
context_fabric_common.v1.schema.json:ClaimedFactgains two optional properties,time_series_table($ref the SAMEClaimedFactTable$deftablealready uses) andtime_series_rows(array of the SAMEClaimedFactRow$defrowsalready uses,maxItems: 64) — no new $defs.table/rowskeep their current meaning and preference unconditionally; this pin is strictly additive. (An unrelated doc-comment-only change toCohort.groups/CohortGroup— CHAOS-4733, description text, no shape change — rode along from an intervening acr commit; verified byte-for-byte that onlydescriptionstrings differ there.)
context_fabric_investigation_request.v1.schema.json and
error.v1.schema.json are byte-identical to the prior pin. The pair is
schema-OPTIONAL — per CHAOS-4656's doctrine this is a NORMAL two-step deploy
(this consumer pin lands first; the acr-side rig serving builds past the
merge waits for it), proven both-shapes in src/lib/acr/validate.test.ts
(the real acr-emitted fixture, whose claim_workload_ask_dev_backlog claim
now carries BOTH a legacy team_breakdown table/rows AND a genuine
daily_workload time series in the new pair, validates as-is; the same
document still validates with the new pair stripped from every claim; an
unrecognized field on a claimed fact still rejects; the dual-table document
is reproduced red against the prior pin's own ClaimedFact $def). UI
change, this PR: the trend-chart resolver (src/lib/render-shapes.ts's
renderableRowsSource/rowsFor) now prefers time_series_rows over the
legacy rows when resolving a claimed_fact_row point — mirroring acr's
own ContextFabricClaimedFact.renderableRows exactly — so a dual-table
fact's server-selected trend renders instead of being withheld as unsourced
(its row indices point into time_series_rows, not the differently-shaped
legacy rows). Telemetry: dualTableTrendChartCount/legacyTrendChartCount
on the workbench_investigation event record which source served each
rendered trend.
Previous pin dbde584b41ddc6b89392344020b23cac7233559e (acr main tip #361,
FactTable gains a third declared column role, observations) landed a
per-row categorical column role (a severity label, an as-of date, a boolean
flag) that used to have nowhere to go but measures, the same slot a
numeric identity column could hide in undetected. No UI change: the declared
table is not rendered by this workbench at all — key/measures/
order_by/observations are unrendered structural fields Ask Dev does not
draw a chart from in this view.
Previous pin d261b265275a6945783496bfa7559dcfa451ba10 (acr main tip #356,
the engine's overlap-aware grouped-narrowing and projection-allowance merge
— an exact minimum set-cover selection, guarded to small group counts with
an untouched greedy fallback beyond it) landed the closed NarrowingBasis
enum's fourth member, overlap_aware_set_cover, in
context_fabric_common.v1.schema.json alone; proven both-shapes the same
way, in the same test file.
Previous pin a6414816049df099dbe066290961897bf1420fa7 (acr main tip #355,
CHAOS-4690/CHAOS-4691 — disclosures speak the engine's own language, and
this repo's consumer-side phrasing tables were ripped out) landed without an
entry here; noted for the record rather than reconstructed after the fact.
Pin before that: 0a65f124b1d70e2acc46542dc642e751f7932434 (acr main tip
#353; CHAOS-4636 "S5" — the answer plan, three-stage budget, and grouped
cohort — bumping past CHAOS-4642's f9d9688c/#352 pin per CHAOS-4668). Two
files in the CONSUMED surface (the four schemas
scripts/sync-acr-contracts.mjs actually copies: context_fabric_common.v1,
context_fabric_investigation_request.v1,
context_fabric_investigation_result.v1, error.v1) changed between the
prior pin and this one — verified per-file directly against acr's own
history (git diff f9d9688c72bf6843137778079f77bd8dde8da32e 0a65f124b1d70e2acc46542dc642e751f7932434 -- contracts/jsonschema/v1/<file>
run once per consumed path, not as one combined command):
context_fabric_common.v1.schema.json: new$defsAnswerPlan,AnswerPlanBudget,PlanNarrowing,CohortGroup, plus the closed vocabularies they reference (question family, group/member kind, narrowing stage/basis — the question-family one promoted from CHAOS-4632's shadow-only vocabulary now that its false-emission rate is measured).Cohortgrows an optionalgroupsproperty.context_fabric_investigation_result.v1.schema.json: +3 lines, one new OPTIONALanswer_planproperty (not in the schema'srequiredarray).
context_fabric_investigation_request.v1.schema.json and
error.v1.schema.json are byte-identical to the prior pin. Every field this
pin adds is schema-OPTIONAL (audited during S5's own review; reconfirmed
here field-by-field against each new $def's own required array) — per
CHAOS-4656's doctrine this is a NORMAL two-step deploy (this consumer pin
lands first; no atomic swap needed, unlike CHAOS-4642's REQUIRED
completeness).
CHAOS-4644's own GroupKind/ScopeAnchorTerm promotion is only PARTIALLY on
the wire at this pin: AnswerPlan.family/.group_kind (S5's own fields)
give CHAOS-4644 its GroupKind half as a side effect, but ScopeAnchorTerm
has zero hits anywhere in acr at 0a65f124 (git grep clean) — CHAOS-4644
stays open, its ticket corrected accordingly.
Rendered minimally, per CHAOS-4668's own scope note and CHAOS-4669 (answer leads, apparatus collapses) — no new lead panel:
AnswerPlanPanel: a single collapsed<details>(same shape asCoveragePanel's "Source details") naming the resolved question family, the budget the plan was built against, and — when present — eachnarrowingstep's before/after counts and basis verbatim (the "showing 2 of 3 teams" disclosure North Star checks 5/12 ask for). Renders in bothDeterministicAnswerViewbranches, gated purely onanswer_plan's presence (absent on every pre-S5 result, so this component is byte-identical to not existing for one).CohortGroupsPanel: a collapsed disclosure of each group's owncomplete/truncatedstate, next toCohortRankingPanel. Gated onCohort.groups's presence rather thaninterpretation.shapeintent —groupsis itself the evidence a grouped answer was assembled. Per lane-4636's measured finding (CHAOS-4668 ticket comment),groupsdoes not co-occur with a ranked cohort on realdh_0830data, so this panel's own test suite is fixture-only — expected, not a gap.
Previously pinned: aa214606e70d9beb1cd2ea78d62a17bd4e680c3b (acr main tip #326;
CHAOS-4449, bumping past CHAOS-4398 PR3/PR3b (#322/#325) — the cohort ranking
surface). The whole widening lands in ONE schema: context_fabric_common.v1's
CohortMember grew ranking_computed, attention_rank, score,
ranking_basis, data_completeness, outcome, missing_signals and
drivers, the last pointing at a new CohortMemberDriver
(signal/value/weight/weight_contributed/window, plus
threshold_labels, concentration/concentration_method and
source_claimed_fact_ids). The investigation-result schema is byte-identical
to the prior pin — cohort was already a result field and only its member
shape grew — so the src/contracts/ diff shows the change where it actually is.
Two things moved in the canonical example, and both had consequences here: it
gained a ranked cohort plus three narrated cohort drivers (what the new
tests read), and its canonical_fact:workload coverage source flipped from
pruned to available, failing a telemetry test that had named those two
states literally (src/lib/telemetry/outcome.test.ts, now asserted against the
example's own source/state pairs instead).
Not in this bump: CHAOS-4413. Its terminal_status, terminal_reason,
rows_count and claimed_facts_count are absent from
contracts/jsonschema/v1 at this pin — they exist only in acr's trial harness
(cmd/acr-trial-merge-two-turn/main.go), which is exactly the harness-only
telemetry CHAOS-4413 exists to promote into the public contract. coverage
(its fifth field) was already public and is already rendered by
CoveragePanel. Rendering the other four would mean inventing fields the
contract does not carry, so they wait for the acr side.
Previously pinned: b8350816ec5823c7c6859a5d88fc917bb318d43b (acr main; CHAOS-4364
follow-up to CHAOS-4355 — bumps past #307 (56316ebe)'s new FactKinds
flow/landscape (bottlenecks, IC landscape/area) and #306 (02c44254)'s
carried StructureSource value for a same-conversation window carry.
A live proof against the prior pin surfaced the gap this closes: ACR itself
answered decisively (claims=4, rows_count=5, composed_kinds including
flow/landscape) but the Workbench's own Ajv validation rejected the 200
response as acr_contract_violation because the pin predated both additions
(dev-health .remember/context-fabric/cf-question-results.md, "20:46
08-27"). Also pulls in #303 (ef303358)'s Rows-into-synthesis routing (the
pinned canonical example itself now carries a rows-bearing readiness/
release_ready fact and a confirmed_structure block) and #309/#310's
response-bound and synthesis-rejection fixes (no contract change). Previously
pinned at 30f38869f6ecc1233ddb903ef962d6be4a806a09 — CHAOS-4355, bumping
past CHAOS-4347's additive rows on ClaimedFact/ProjectedFact
(ContextFabricClaimedFactRow, #300), so ClaimedFact.rows renders as a
table or chart. Also pulled in CHAOS-4335/CHAOS-4336's window-gate fixes and
CHAOS-4348's subject-pool reachability fixes. Before that, pinned at
e946ad907cdfd66b45895839c7adb65f2e436808 — CHAOS-4171 PR3, adding bounded
offer phrasing (acr PR2, #263) on the
expected_kind/subject_anchor/subject_handle/subject_candidate option
types, and SubjectResolution.prior_subject_receipt_dispositions (CHAOS-3478/
CHAOS-3813, #265), on top of CHAOS-4012's ranked-candidate-list structure
offer axis, #236/#175/#242, and the CHAOS-3927 P1 #159 + CHAOS-3900 W1 #158 +
disclosure-coverage #160/#161 baseline described below).
WindowOption type generation. This pin's WindowOption schema (CHAOS-3900
W1 §5.1's frozen-bounds allOf/anyOf/not conditionals) combined with the
maxItems: 20 bound on the arrays that carry it (StructureNeeds.window_options,
WindowClarification.options) exceeds TypeScript's own type-complexity
budget (TS2590, reliably, even for a two-element array). Fixed by dropping
those three conditional keywords from the schema copy sync-acr-contracts.mjs
feeds to json-schema-to-typescript ONLY — see that script's own
stripWindowOptionConditionalsForTypeGeneration comment. The copy committed
to src/contracts/schemas/ (and everything validateContract actually runs
against at request/response time) is untouched and still byte-identical to
the pinned commit's own blob; only the TYPE loses information TypeScript
could never have used anyway (conditional validation isn't representable as
a structural type). Every other field's maxItems tuple-union typing (e.g.
prior_subject_receipts, already documented in @/lib/acr/client.ts's
buildInvestigationRequest) is unaffected.
next is pinned to an exact version, not a caret range, so an install can
never drift onto a line this repo hasn't verified.
2026-09-09: moved to 16.3.3, ahead of the web upgrade. The prior pin
(16.2.12) carried two unpatched Next.js unauthenticated-RCE criticals that
only a real next version bump clears -- unlike the sharp/postcss/fast-uri
advisories on this same dependency, which are handled as
pnpm-workspace.yaml overrides without touching next's own pin. Advisory
references (GitHub Security Advisory database):
16.3.3 is the first patched version, same major line, verified against the
full local gate (format, contracts, lint, typecheck, unit, build) plus a
real next build before landing. next
staying pinned exactly, rather than caret, is unchanged -- only the pinned
value moved. dev-health-web has NOT taken this upgrade yet and remains on
its own timeline (tracked as its own ticket); this repo no longer runs
exactly what that repo runs on this one axis until web catches up.
Things worth knowing before touching the client, learned against the live service rather than from documentation:
- A rejected server credential surfaces as 502, not 401. The server hop holds the ACR credential, so an ACR rejection says nothing about the browser session. Proxying it as 401 would invite a client-side re-auth that cannot possibly help.
repository_scopesmust not be empty.validWebRepositoriesin acrinternal/auth/web_assertion_binding.go:34-37opens withif len(scopes) == 0 { return false }, and the whole assertion then fails asinvalid_web_assertion— reaching the caller as a bare401 invalid_tokenthat says nothing about scopes.dev-health-webnever trips this because its scopes always come from a resolved org authorization.signWebAssertionguards it locally so a new consumer gets a named error instead. Recorded as an observation, not a patch: acr is not this repo's to change, and failing closed on an empty scope set may well be deliberate.- TLS is ON by default for the graph dial, and
ALLOW_INSECUREdoes not turn it off.ACR_CONTEXT_FABRIC_FALKOR_TLSdefaults to true whileACR_CONTEXT_FABRIC_FALKOR_ALLOW_INSECUREonly relaxes certificate validation. Against a plaintext FalkorDB port the dial TLS-handshakes and hangs until a timeout, so an authenticated investigation reaches the engine and dies there — presenting as a slow, generic timeout rather than a connection error. SetACR_CONTEXT_FABRIC_FALKOR_TLS=falsefor a plaintext local backend. - A 503 from the investigations route is one wire signal for several
possible causes, not a single diagnosis. ACR's
upstream_unavailable503 covers every backend dependency its error envelope has no room to distinguish: the investigator genuinely not being composed (ACR_CONTEXT_FABRIC_GRAPH_READS_ENABLED, a configured graph backend --ACR_CONTEXT_FABRIC_FALKOR_ADDR, with thecontext-fabric-graphcompose profile up -- or a configured model provider) is one cause; a failure in ACR's own result persistence (e.g. a schema/CHECK-constraint mismatch, CHAOS-4333) is a completely unrelated one that fires the identical code+status. Match the request id against ACR's own logs rather than assuming which cause this is. - A 504 is not an unreachable service. ACR's global
ACR_REQUEST_TIMEOUTdefaults to 15s while its model call budget defaults to 45s, so a real model-backed investigation can exhaust the HTTP budget while the pipeline is still running. The Workbench reports that asacr_timeout, separately fromacr_unreachable, because the two lead to different investigations. - ACR's status codes are a deliberate classification — read them literally.
422 interpretation_rejected/synthesis_rejectedis ACR's own validator rejecting an artifact it derived (a classified non-answer, retryable);502 upstream_invalid_outputis the provider misbehaving;503is a dependency down;429is rate limiting.500 internal_erroris the unclassified fallthrough —context_fabric_routes.gosays in as many words that a bound violation "is not the provider misbehaving (that stays 502) and not an ACR bug (that stays 500)". So a 500 here means an ACR-side fault, not a model being picky, and retrying it will not help — ACR itself marks itretryable: false. - An investigation is not guaranteed to answer on the first call, and the
Workbench deliberately does NOT auto-retry. ACR's own operations guide is
explicit that
422 interpretation_rejected,422 synthesis_rejected, and502 upstream_invalid_outputare expected, retryable outcomes even with the fallback model configured. A product client should probably retry; this one must not. Its whole purpose is measuring answer quality, and silently re-rolling until something succeeds would hide the rejection rate — the exact number a tester is here to see. The outcome is surfaced with its retryable flag and the tester decides. - A 5xx is not an unreachable service either. ACR deliberately keeps the
underlying reason for an engine failure off the wire, so the Workbench reports
acr_investigation_failedand surfaces ACR's ownrequest_id— the only handle for matching the failure against ACR's logs. It never guesses a cause. - The graph key is derived, not literal. Reader and projector both call
graphKey(prefix, orgID), so the live graph isacr-cf-<hash>, notacr-cf-<org-uuid>. An emptyacr-cf-<org-uuid>key in FalkorDB is a leftover and is not the graph being read.
src/test/fixtures/investigations.ts holds four scenarios: complete (the
canonical example, unmodified), degraded, clarification, and no-match.
These are test inputs only and may never be presented as answers. An ESLint
no-restricted-imports rule fails the build if anything under src/app,
src/components, or src/lib imports them.
Every scenario is a structural clone of the pinned canonical example with named
fields overridden. Mocks mirror the real vocabulary — nothing is invented.
Values come from the contract's own closed enums, or from strings the ACR
service itself emits (canonical_fact:<kind> source names, the
pruned:subject_kind_unsupported: … prune reason, "<fact kind>: <reason>"
degraded entries, endpoint_lookup_failed:<n>).
src/test/fixtures/investigations.test.ts validates every scenario against the pinned
schemas, and carries the negative controls that make that validation mean
something: an invented coverage state, a missing required field, and an invented
subject-candidate state must all be rejected.
There is no
retrieval_degradedcoverage state. That term appears nowhere in the ACR contracts or service. The contract expresses degraded retrieval ascoverage.partial: truepluscoverage.degraded_reasons[], alongside the real states (stale,unavailable,truncated,pruned, …) — which is what thedegradedscenario uses.
One stylesheet, src/styles.css, plain class names, CSS custom properties, dark
by default with a light prefers-color-scheme block.
Hard rule: no bright borders on the dark theme. Separation comes from surface elevation and low-alpha hairlines, never from a light outline on a dark ground.
The invariant, stated once: every length bound in this repo counts Unicode
code points, because ACR counts runes (Go's RuneCountInString) and that is
also what JSON Schema's maxLength means. JavaScript's .length counts UTF-16
units, so an astral character counts twice — a question of exactly 8000 astral
code points measures 16000 and would be rejected by a naive guard that ACR would
have accepted.
Three idioms enforce it, and they are deliberately not unified behind one helper — consistency of behaviour is the invariant, consistency of idiom is aesthetics:
| Where | Idiom |
|---|---|
src/app/api/investigations/route.ts |
codePointLength (spread) — question and receipt ids |
src/lib/acr/upstream-vocabulary.ts |
u-flagged regex quantifier — its {1,128} counts code points because of the flag, and it does charset and length in one pass |
src/lib/acr/validate.ts (Ajv) |
ucs2length — verified by probing the pinned dependency, not by reading docs |
Byte bounds are a separate thing and are measured in bytes
(exceedsResponseCap), not in either character unit.
If you add a length bound, count code points, and add an astral boundary test — a correct fix with only ASCII fixtures is indistinguishable from a fix never made, which is how the receipt bound sat unpinned through a review round.
Five instances of one shape appeared during CHAOS-3738 — the Workbench
presenting a state as normal when something was silently discarded or
substituted. src/lib/silent-discard-closure.test.ts closes the class rather
than chasing a sixth: it enumerates every seam where a result or user input
crosses a boundary, with a verdict of structurally impossible (mechanism
named), detected and surfaced, or accepted gap (real owner).
15 seams; 2 accepted gaps, both owned — CHAOS-3813's missing wire signal, and ACR stage latency (the spec asks for it and ACR exposes none, so it is recorded as total and labelled rather than apportioned by guess). The gap list is deliberately non-empty: a table whose every row read "impossible" would be the same fails-toward-fine shape it exists to close.
Those counts are asserted in the test, by exact gap name and exact seam count — the artifact once carried different numbers from the README and from a status report, and three numbers in three places was itself a finding. Drift now fails the suite instead of waiting for someone to count rows.
One row carries a precondition rather than a gap: model-authored
compositions do not exist yet (the only producer is buildComposition), so the
seam is unreachable. That inventory is enforced rather than asserted —
producer-inventory.test.ts scans the source and requires the producer set to
be exactly [buildComposition], so adding a model-authored producer fails that
test, and the failing test is the re-verdict trigger the precondition
promises.
The recurring reason these hid: every one produced output that looked like ordinary safe operation. Falling back looks safe. An empty section looks like "nothing to report". A fresh clarification looks like a fresh question.
When ACR cannot commit a subject it returns clarification_required with ranked
candidates, and the tester chooses one. Four rules hold this path honest:
- A clarification is not a failed answer, and is not rendered as a thin one. The choice leads; empty judgment panels do not appear above it.
- The choice travels as ACR's own
receipt_id, through the contract'sprior_subject_receipts, never as a re-typed subject name. The Workbench therefore never names or authorizes a subject on a tester's behalf, and the UI can only offer candidates the result already contains. - The question is re-sent unchanged. Rewriting it to mention the chosen subject would make the Workbench author part of the question, and ACR would then be answering something the tester never asked.
- Candidate order is ACR's. Re-sorting by confidence would be the presentation layer quietly forming a judgment ACR did not make.
Receipts are deduplicated and capped at the contract's maxItems of 20 before
being sent. A receipt arriving from the browser that is malformed rejects the
request rather than being filtered out of it: a discarded receipt would mean
the re-ask ran without the chosen subject, and the tester would get a fresh
clarification with no sign their choice was thrown away.
Identity is ACR's to enforce, and it does — verified at pin 0ed4e1a that the
result lookup is org-scoped in SQL (pginvestigation/store.go:202-203) and that
a receipt must match a candidate of that same result (engine.go:404-414). The
route validates shape only.
ACR used to discard a receipt silently when the prior result was
unreadable, no candidate matched, or the subject was unauthorized —
engine.go:417-427 states that "Investigate itself never errors or
otherwise surfaces the skip". Before CHAOS-3813 landed, the result schema
carried no receipt disposition at all, so the Workbench detected it
instead: after a re-ask, the chosen subject is compared against
subject_resolution.committed by canonical id, and a mismatch is reported
in both shapes — an answer about another subject, and a second
clarification that would otherwise let a tester loop forever
(@/lib/clarification.ts).
CHAOS-3813 has landed (acr PR #265, pin e946ad90, CHAOS-4171 PR3):
SubjectResolution.prior_subject_receipt_dispositions is now a wire-visible,
per-receipt disclosure (applied or one of four skipped_* reasons),
rendered by PriorSubjectReceiptDisclosure in both the decisive path
(inside SubjectResolutionPanel) and the clarification_required path
(directly in DeterministicAnswerView, since that branch renders
ClarificationPanel instead of SubjectResolutionPanel and would otherwise
duplicate its candidate list). The client-side detection above is kept
anyway: defense in depth on a measurement instrument is not dead code, and
this pin bump does not delete it as such.
The pivot-intent design brief (dev-health .remember/pivot-intent-design-brief.md,
DESIGN-FINAL) names ask-dev the panel surface of record (DP6(c)) for a new
disclosure block, structure_needs: when ACR cannot even settle which census
to run, it can name WHICH intent-frame members are missing (kind, anchor,
handle, window, and — CHAOS-4012's ranked-candidate-list axis —
subject_candidate) and offer typed, receipt-bound completions for each,
instead of a dead-end refusal. StructureNeedsPanel renders exactly those
offers, StructureConfirmationNotice renders the confirmed_structure echo
(including vetoed selections — the silent-drop closure for structure, day
one, unlike the subject-receipt path above). Both extend the disambiguation
flow's own rules verbatim: receipts only, never re-ranked, never invented,
free text never becomes a discriminator.
Offer phrasing (CHAOS-4171 PR3, acr PR2 #263). KindOption/AnchorOption/
HandleOption/CandidateOption (not WindowOption) carry an optional
phrasing string: model-generated presentation wording for the SAME
structural offer, produced under acr's own closed-vocabulary guard
(rejects/falls back to the structural value on a guard violation, timeout, or
call failure). The offer VALUE stays structural — receipt_id/option_id
and what gets submitted on selection are unaffected by phrasing.
StructureNeedsPanel displays phrasing ?? label as each offer's title and
button text, but always shows the structural label alongside it when
phrasing is present (same rule @/lib/presentation.ts holds for its tone
maps: the raw contract term is never hidden behind generated wording).
THE SEAM LANDED. P1 (CHAOS-3927 #159) and CHAOS-3900 W1 (#158) merged to
acr main, and this repo's pin bumped past that merge (7d275c2e, "Bumping
the pin" above) — structure_needs/confirmed_structure/
structure_offer_snapshot/window_clarification are now real, generated
fields (src/contracts/generated/investigation-result.ts), and the four
prior_*_receipts request fields are real too
(src/contracts/generated/investigation-request.ts). The hand-mirrored
src/lib/pivot/structure-contracts.ts and its staging schema
(structure-needs.pending-p1.schema.json) — this section used to describe
them — are deleted; every consumer still imports these names through
@/lib/contracts, which now re-points them at the generated modules (plus a
handful of vocabulary aliases derived by indexed access, since
json-schema-to-typescript inlines those five enums rather than naming them
— see that file's own header). src/test/fixtures/structure-needs.ts
validates directly against the real pinned contract now
(structure-needs.test.ts), no fragment-stripping.
buildInvestigationRequest in src/lib/acr/client.ts still attaches the
four prior_*_receipts fields only when non-empty — that used to be a
correctness requirement (the pre-seam pinned schema's additionalProperties: false would reject them outright); post-seam it is wire minimization only,
since an empty array is just as schema-valid as an absent key. client.test.ts
still pins the omit-when-empty behavior.
Real e2e coverage for the panel-hint flow now exists too:
tests/support/fake-acr-server.mjs's TRIGGER_STRUCTURE_NEEDS case and
tests/chat.spec.ts's "structure needs chips" describe block — see that
file's own disclosure block for what changed and why. src/app/page.test.tsx's
structure-needs chips suite (a schema-shaped mock fetch response) remains
as the unit-level companion, same discipline the clarification flow's own
page tests already use.
src/lib/enrichment/ holds the pieces the enriched view will sit on. The tab
stays disabled until a real answer renders in the deterministic view and
every validator predicate test is green.
manifest.ts— the Dev Health-owned presentation manifest. It declares the closed component set, which props are material, the closed vocabularies for the rest, and the mandatory sections. It carries no answer, fact, metric, or judgment; only headings and layout vocabulary. The manifest, not OpenUI, is the product boundary.library.tsx— the closed component library. Every renderer takes noclassName,style,href, orsrc, declares no action, and renders text as a React text child, never throughdangerouslySetInnerHTML.refs.ts— reference-only resolution. A material prop may never be a literal; it must be a@result.reference resolved against the immutable result. Only own properties and integer array indices resolve, and only to scalars, so@result.constructor,@result.__proto__.x, and@result.limitations.lengthall fail.validate.ts— the fail-closed validator. It parses the WHOLE composition and checks every predicate before anything mounts, because OpenUI's renderer is progressive by design: left to itself it drops the offending node and renders the rest.
Two predicates exist because of behaviour observed in a probe, not because the
documentation suggested them — and a validator without them would have shipped
holes. Both depend on undocumented fields of OpenUI's ParseResult
(meta.unresolved, and the hasDynamicProps flag on a parsed node), so the
dependency is named here deliberately: a future OpenUI version could rename
either. The predicate tests assert the violation is produced, so a rename
fails the suite loudly rather than silently disabling the check.
meta.unresolvedis populated whilemeta.errorsstays empty for a dangling reference. Checking onlyerrorspasses a composition that references nothing.- Built-in functions (
@Count(...)) and$statevariables raise no error; they appear only as nodes flaggedhasDynamicProps. That flag is the tell for a model-computed value, which the spec forbids outright.
Each predicate has a hostile payload in validate.test.ts, plus a passing
control — without one, a validator that rejected everything would look perfect.
@openuidev/react-lang (caged renderer) and @openuidev/react-headless (chat
hooks) will be adopted in M3. @openuidev/react-ui was evaluated and
deliberately excluded: it pulls roughly twenty extra dependencies including
sixteen Radix packages and its own design system, and it contains the only two
things in that codebase we must not ship — a dangerouslySetInnerHTML and a
window.open. Please do not add it back.
OpenUI is a replaceable adapter. The enrichment interface and the manifest are the product boundary; Query, Mutation, MCP, arbitrary URL, HTML, JavaScript, CSS, external embeds, and model-authored factual props are all unavailable by construction, and the whole composition is validated before anything renders.
@openuidev/lang-core ships an install-time telemetry postinstall. The
allowBuilds allowlist in pnpm-workspace.yaml blocks it, and CI additionally
sets DO_NOT_TRACK and OPENUI_TELEMETRY_DISABLED.