Skip to content

feat(portal): the capability gate, and /verify as its first surface - #20

Merged
emooreatx merged 18 commits into
mainfrom
feat/portal-capability-gate
Aug 29, 2026
Merged

feat(portal): the capability gate, and /verify as its first surface#20
emooreatx merged 18 commits into
mainfrom
feat/portal-capability-gate

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

First CIRISPortal surface, and the first shipped ahead of its API. This is the pattern test, not the screen.

The gate is three-state, and that's the point

PRESENT node declared it holds this
ABSENT node declared its set, this wasn't in it
UNDECLARED node said nothing

Every node released today is UNDECLARED — the declaration is CIRISServer#499 and hasn't landed. Collapsing that into ABSENT would hide surfaces a newer node will serve, and leave an operator unable to tell a missing feature from an old node.

The two render as different sentences because they have different remedies:

  • undeclared → "It's running a version from before nodes declared what they can do." → upgrade
  • absent → "Build verification is part of the registry, and this node doesn't carry it." → different node

Collapsing them tells someone to go find another node when their own just needs updating.

Not a security boundary, and the code says so

From your TRUST_ROOT_CAPABILITY_GATE.md §5: "the server enforces the reality whether or not the client showed it (the warning informs; the gate binds)." This is the informing half. Wrong permissively → the server still refuses. Wrong restrictively → an operator sees a working feature marked unavailable, which is exactly why UNDECLARED doesn't render as ABSENT.

Not-found vs couldn't-ask

Rendered apart, and the copy says it: "This is not the same as 'not registered' — the registry did not answer." On a revocation check, showing a transport failure as "no record" tells someone an unverified build was checked and cleared.

Same rule for an unreadable status — shown verbatim, never mapped to something adjacent. Guess REGISTERED and a revoked build looks fine; guess REVOKED and a good one is condemned.

Verified

The probe distinguishes all three states against the real conformance shapes, and reads the live node as UNDECLARED as expected. 11 tests. Desktop + wasmJs compile; localization and vendoring green.

Next: wire the probe into startup, and use the walk-test facade to present a node that declares registry:lookup — proving the PRESENT path before #499 lands.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

emooreatx and others added 2 commits August 28, 2026 10:14
CIRISPortal's surfaces come here as CIRISRegistry folds into CIRISServer, and
almost none of their APIs exist on a released node yet. This is the machinery
for shipping the UI first — and the first slice proving it end to end.

UNDECLARED IS NOT ABSENT, and that is the whole design. Three states:

  PRESENT     the node declared it holds this capability
  ABSENT      the node declared its set and this was not in it
  UNDECLARED  the node said nothing at all

Every node released today is UNDECLARED, because the declaration is
CIRISServer#499 and has not landed. Collapsing that into ABSENT would hide
surfaces a newer node will serve, and leave the operator unable to tell a
missing feature from an old node. It is the same distinction as `ModeProbe`'s
undetermined and `LookupResult`'s not-found-versus-no-answer: "I could not ask"
is not "the answer is no", and this codebase has now paid for that lesson three
times.

NOT A SECURITY BOUNDARY, and the code says so where someone might assume
otherwise. CIRISServer's TRUST_ROOT_CAPABILITY_GATE.md §5: "the server enforces
the reality whether or not the client showed it (the warning informs; the gate
binds)." This is the informing half. Wrong permissively, the server still
refuses; wrong restrictively, an operator sees a working feature marked
unavailable — which is why UNDECLARED does not render as ABSENT.

The probe is a narrow scrape of /v1/federation/conformance, never throwing: a
node that is down, slow, or serving an older document is undeclared, which is
the honest reading of all three. Verified against the three real shapes plus the
live node, which reads UNDECLARED as expected.

FIRST SLICE: /verify — look an agent build up by hash. Public, read-only, and
registry-shaped (identity and revocation is what the fold's own FSD calls
registry work). Small enough to prove the pattern, useful on its own.

An unreadable status decodes to UNKNOWN rather than being guessed. On a
revocation check both guesses are lies: REGISTERED makes a revoked build look
fine, REVOKED condemns a good one. The raw string is kept and shown.

11 tests. Screen and wiring follow; this is the part the surfaces sit on.

Refs CIRISServer#499, CIRISRegistry#62.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
…not serve it

The first CIRISPortal surface, and the first shipped ahead of its API. No node
released today serves `/v1/registry/lookup`, so WHAT THE SCREEN DOES WHEN THE
CAPABILITY IS MISSING is the actual feature here — the lookup itself is the easy
half.

FOUR OUTCOMES, NONE OF THEM A BLANK SCREEN OR AN ERROR:

  UNDECLARED  "This node hasn't said whether it can verify builds." It predates
              the declaration (CIRISServer#499); a newer node will answer, and
              upgrading is the remedy.
  ABSENT      "This node doesn't verify builds." It declared its capabilities
              and this was not among them; a different node is the remedy.
  PRESENT     render the lookup.
  UNKNOWN     a status this client cannot read — shown verbatim.

The first two are separate sentences because they have separate remedies, which
is the entire reason the gate has three states instead of a boolean. Collapsing
them would tell someone to go find another node when their own node just needs
updating.

NOT-FOUND AND COULD-NOT-ASK ARE RENDERED APART, and the copy says so out loud:
"This is not the same as 'not registered' — the registry did not answer." On a
revocation check, showing a transport failure as "no record" tells an operator
an unverified build was checked and cleared. `found: false` is the registry
answering; a timeout is not.

An unreadable status is shown as the raw string rather than mapped to something
adjacent. Guess REGISTERED and a revoked build looks fine; guess REVOKED and a
good one is condemned.

The lookup call is gated by the CALLER on Capability.REGISTRY_LOOKUP rather than
gating itself, so a missing capability never becomes a connection error the
operator has to interpret.

Desktop and wasmJs compile; localization and vendoring green.

Refs CIRISServer#499, CIRISRegistry#62.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80189a600f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

* than guessing; on a revocation check a guess is a lie.
*/
@Composable
fun VerifyAgentCapabilityNotice(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Wire the verification surface into navigation

The new verification UI is unreachable: a repo-wide search at this commit finds VerifyAgentCapabilityNotice, VerifyAgentResult, getNodeCapabilities, and lookupAgentHash only at their definitions, while CIRISApp adds neither a Screen/NavSurface route nor a caller. Consequently users cannot enter a hash, probe the capability, perform the lookup, or render either result composable, so the advertised /verify surface ships no usable feature.

Useful? React with 👍 / 👎.

Comment on lines +63 to +64
CapabilityState.UNDECLARED -> "This node hasn't said whether it can verify builds"
else -> "This node doesn't verify builds"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route verification copy through localization

In every non-English locale, these hard-coded strings bypass LocalizationHelper and the locale bundles, so the new capability notice and all result states remain English regardless of the selected language. Add localization keys and run them through the repository's translation/evaluation/repair process rather than shipping English as the fallback for this entire surface.

AGENTS.md reference: AGENTS.md:L15-L17

Useful? React with 👍 / 👎.

return ai.ciris.mobile.shared.models.capability.LookupResult.Found(
ai.ciris.mobile.shared.models.capability.AgentRecord(
agentHash = str("agentHash").ifBlank { agentHash },
agentType = str("agentType"),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Parse the registry's snake-case response fields

When the folded CIRISServer endpoint follows the repository's existing wire convention, the record fields arrive as agent_hash, agent_type, registered_at, and has_attestation (the checked-in OpenAPI schemas use snake case throughout, and this request itself uses agent_hash). These camel-case regexes therefore silently discard the returned type, registration time, and attestation flag and replace the returned hash with the query value; parse the wire names or use a serializable model with explicit SerialName mappings.

Useful? React with 👍 / 👎.

)
}
resp.bodyAsText()
} catch (e: Exception) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve coroutine cancellation during registry probes

When the lookup's owning scope is cancelled—for example because the user leaves the verification screen while the request is pending—this broad Exception catch also consumes CancellationException and converts it into an ordinary Unavailable result instead of terminating the coroutine. getNodeCapabilities has the same problem through runCatching; rethrow cancellation before mapping genuine transport failures so cancelled probes cannot continue and publish stale state.

Useful? React with 👍 / 👎.

.map { it.groupValues[1] }
.toSet()
ai.ciris.mobile.shared.models.capability.NodeCapabilities(ids)
}.getOrElse { ai.ciris.mobile.shared.models.capability.NodeCapabilities.UNDECLARED }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep probe failures distinct from undeclared capabilities

When the conformance request times out, is refused, returns malformed data, or otherwise throws, this maps the failure to UNDECLARED; the UI then asserts that the node predates capability declarations and recommends upgrading it. A current node with a transient transport failure therefore receives a false version diagnosis, recreating the could-not-ask-versus-answer distinction that LookupResult.Unavailable preserves. Represent probe failure as a separate pending/unavailable state and reserve UNDECLARED for a successfully read document that omits the field.

Useful? React with 👍 / 👎.

}

suspend fun getNodeCapabilities(
nodeUrl: String = LOCAL_NODE_URL,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Probe the configured node instead of localhost

In browser deployments and desktop installations configured with a remote nodeBaseUrl, calling this API with its default probes 127.0.0.1:4243 rather than the node attached to CIRISApp; lookupAgentHash has the same default. The capability verdict and subsequent registry lookup can therefore come from a different node—or simply fail in the browser—even though the rest of the UI is connected to the configured origin. Make the attached node URL mandatory here or supply the configured node base rather than LOCAL_NODE_URL.

Useful? React with 👍 / 👎.

…lish-only

Six findings, and the first is the one I should have caught: THE PROBE COMMITTED
THE CONFLATION THE MODEL EXISTS TO PREVENT.

`getNodeCapabilities` mapped every failure — timeout, refusal, unreadable
document — onto UNDECLARED. The UI then told the operator their node predates
capability declarations and recommends upgrading it. A current node with a
dropped connection got a FALSE VERSION DIAGNOSIS, and I wrote that inside the
model whose entire argument is that "I could not ask" is not "the answer is no".

CapabilityState now has four:

  PRESENT      declared, and holds it
  ABSENT       declared, and does not
  UNDECLARED   the document was READ and carries no list — an older node
  UNREACHABLE  the document could not be read at all

UNREACHABLE also wins over any set we happen to hold: a probe that failed cannot
license the UI on a stale answer. The copy differs for it, because the remedy is
neither "upgrade" nor "use another node" — it is "try again".

AND FIVE MORE:

- Cancellation was swallowed. `runCatching` and a broad `catch (Exception)` both
  consume CancellationException, so leaving the screen mid-request turned a
  cancelled coroutine into an ordinary result that then published state.
  Rethrown before mapping transport failures, in both calls.

- Both calls defaulted to LOCAL_NODE_URL while the app may be attached to a
  remote node — the same wrong-node bug as the reset home resolution, two days
  younger. The node URL is now mandatory: there is no default to be wrong.

- The record parser read camelCase only. The checked-in OpenAPI schemas are
  snake_case throughout and this request itself sends `agent_hash`; the Portal's
  TypeScript is camelCase because Next.js maps it. So type, timestamp and
  attestation were silently dropped — and `agentHash` fell back to the QUERIED
  value, which would have shown the operator the hash they typed as though the
  registry had returned it. Snake case first, camel as fallback.

- Every string was hard-coded English, bypassing the bundles in 28 locales. 18
  ids added; the lane fills the rest.

Navigation wiring is next, and it is the remaining finding: these composables
have no route and no caller yet, so the surface is not reachable.

Refs CIRISServer#499.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6a781f24fe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
}
val body = try {
client.get("$nodeUrl/v1/federation/conformance").bodyAsText()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat failed conformance responses as unreachable

When the conformance endpoint returns a non-success response such as 404 or 500, bodyAsText() still supplies the error body, which normally contains no capabilities array, so this falls through to UNDECLARED and tells the operator that the node merely predates capability declarations. The current revision distinguishes thrown transport failures, but this fresh HTTP-status path still recreates the same false version diagnosis; inspect resp.status.isSuccess() as the lookup method does and map failures to UNREACHABLE.

Useful? React with 👍 / 👎.

return ai.ciris.mobile.shared.models.capability.LookupResult.Found(
ai.ciris.mobile.shared.models.capability.AgentRecord(
// The RETURNED hash, never the queried one — see above.
agentHash = str("agent_hash", "agentHash").ifBlank { agentHash },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the registry to return the verified hash

When a 200 response contains a status but omits agent_hash—for example, an incompatible or truncated record—the current raw.isBlank() && hash.isBlank() guard accepts it, and this fallback displays the operator's queried hash as though the registry returned it. Fresh evidence after the snake-case fix is that the queried-hash substitution remains reachable whenever only the hash is missing; return Unavailable unless the response itself supplies the hash.

Useful? React with 👍 / 👎.

…rs are gone

WIRING — the remaining P1. The composables had no route and no caller, so the
PR claimed a surface and shipped none. `Screen.VerifyAgent` is now a flow-only
route (no sidebar, like ClaimNode), rendered in the main `when`, with a back
target, and reached from ManageNodes beside "claim ownership" — both are
registry-shaped questions about a node's identity and that is where an operator
already goes to ask them. The compiler enforced most of this: two exhaustive
`when`s refuse a route with no branch.

The capability probe runs in `CIRISApp` against `nodeBaseUrl` and re-probes on a
node switch, because a cached answer from the previous node licenses the wrong
UI. It starts UNREACHABLE rather than UNDECLARED: before the first probe we have
not asked.

AND TWO MORE FABRICATED ANSWERS, both in code I had just "fixed":

- A NON-SUCCESS CONFORMANCE RESPONSE STILL HAS A BODY, and that body has no
  capabilities array — so a 404 or 500 fell through to UNDECLARED and told the
  operator their node predates the declaration. I fixed the thrown path and left
  the status path: the same false version diagnosis, reached by a status code
  instead of an exception.

- THE HASH GUARD WAS AN `AND`. A record carrying a status but no `agent_hash`
  passed it, and the fallback then displayed the hash the OPERATOR TYPED as
  though the registry had returned it. On a revocation check that is the worst
  available lie — it shows someone their own input, confirmed. The fallback is
  deleted; a record without the hash it verified is Unavailable.

Both are the same shape as the six before them: a path that invents an answer
rather than admitting it does not have one. That is the only thing this surface
is really about, and it keeps being the thing I get wrong.

Desktop and wasmJs compile; 14 capability tests green.

Refs CIRISServer#499.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Self-review, not a review finding — the reviewer is rate-limited and this is the
class it had found eight times on this file, so I went looking rather than
waiting to be told.

`result`, `hash` and `inFlight` were remembered with no key, and nothing cleared
them on a node switch. So: verify a hash against node A, switch to node B, look
at the screen — A's verdict is still there, presented as B's answer. A
revocation result attributed to a registry that never gave it, which is worse
than no answer and looks identical to a good one.

All three are keyed on the node URL now, and the screen takes that URL rather
than inferring it, so the identity the result belongs to is explicit in the
signature.

Same shape as every finding on this PR: a path that displays an answer it does
not have. The three-state gate exists because a boolean would have hidden them,
and this one was hiding behind a `remember` with no key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

THE SERVER SHIPPED IT. `src/conformance.rs` now carries
`capabilities: Option<Vec<String>>` (CIRISServer#499), and its doc comment holds
the line I asked for and states it better than my issue did:

    `null` when this node could not read its own key record, which is NOT the
    same fact as `[]` — "no capabilities" and "could not determine" must not
    collapse into one answer, or a client renders a transient directory error as
    a node with no authority.

MY PARSER COLLAPSED EXACTLY THAT. It matched only the array form, so
`"capabilities": null` read identically to a missing key and the UI told the
operator of a CURRENT node that it predates the declaration. Third form of the
same false version diagnosis on this surface.

Four shapes, four answers:

  key absent             UNDECLARED    an older node — upgrade
  "capabilities": null   UNDETERMINED  the node cannot read its own key record
                                       — its answer, not our failure; retry
  "capabilities": []     ABSENT        declared, holds nothing — another node
  "capabilities": [...]  membership

A key present but neither null nor an array reads UNREACHABLE rather than being
guessed into one of the four.

Three remedies, three states, and the server drew the same line independently —
which is the useful signal here: the distinction is a property of the problem,
not a preference of mine.

ALSO THE ENGLISH, twice over. The Hausa reviewer rejected
`verify_undeclared_body` for two reasons and both were mine: "running a VERSION
from before" collides with the glossary's Version→Date mapping and read as a
calendar date, and "a newer node will answer THIS" has no antecedent, so the
translator added "the question" and was marked for adding nuance. Both words are
gone: "This node is older than the capability declaration, so it cannot say. A
newer node can verify builds."

Refs CIRISServer#499.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 97a1d8eba6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +3619 to +3620
nodeUrl = nodeBaseUrl,
onLookup = { hash -> apiClient.lookupAgentHash(hash, nodeBaseUrl) },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind verification to the selected node

After a user switches profiles in Manage Nodes, NodeSwitcherViewModel.switchTo updates apiClient.baseUrl and activeProfileId, but the nodeBaseUrl argument here remains the startup node. Consequently the capability effect is not re-run and both this screen identity and lookup continue using the original node, potentially presenting that registry's registered/revoked verdict as the selected node's answer. The earlier explicit-URL fix does not cover this internal switch path; derive the URL and capability probe from the active profile.

Useful? React with 👍 / 👎.

Comment on lines +9232 to +9238
val returnedHash = str("agent_hash", "agentHash")
// THE REGISTRY MUST SAY WHICH HASH IT VERIFIED. The guard was `raw AND
// hash both blank`, so a record carrying a status but no hash passed —
// and the fallback then displayed the hash the OPERATOR TYPED as though
// the registry had returned it (Codex, PR #20). On a revocation check
// that is the worst possible lie: it shows their input confirmed.
if (returnedHash.isBlank()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject records for a different queried hash

When a malformed or inconsistent 200 response supplies a valid status and a nonblank agent_hash that differs from the requested agentHash, this guard accepts it and returns Found. The verification surface can therefore show a positive verdict beside the user's original query even though the record belongs to another build; displaying the returned hash is insufficient for long, visually similar hashes. Fresh evidence after the missing-hash fix is that only blank hashes are rejected, so compare the returned and queried hashes before accepting the verdict.

Useful? React with 👍 / 👎.

Comment on lines +474 to +475
LaunchedEffect(nodeBaseUrl) {
nodeCapabilities = apiClient.getNodeCapabilities(nodeBaseUrl)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Provide a way to retry the capability probe

When the initial conformance request fails transiently or returns capabilities: null while the node is starting, this one-shot effect stores UNREACHABLE or UNDETERMINED and never probes the same URL again. Navigating away and reopening Verify does not restart an effect keyed only on nodeBaseUrl, and the screen offers no refresh action, so the lookup form remains disabled for the rest of the app session even after the node recovers despite the UI telling the operator to try again shortly. Add an explicit retry or a bounded re-probe for these transient states.

Useful? React with 👍 / 👎.

capabilities = nodeCapabilities,
nodeUrl = nodeBaseUrl,
onLookup = { hash -> apiClient.lookupAgentHash(hash, nodeBaseUrl) },
onBack = { currentScreen = Screen.Interact },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return from verification to a node-valid screen

In a bare-node session, Verify is opened from Manage Nodes, but this back callback sends the user to Screen.Interact, an agent surface that the probed mode intentionally removes from the node sidebar; the system-back mapping added for Verify has the same destination. A node user therefore exits the verification flow onto a screen their attached node cannot serve instead of returning to Manage Nodes or homeTarget. Route both back paths to the originating node-management screen or the mode-derived home.

AGENTS.md reference: AGENTS.md:L62-L62

Useful? React with 👍 / 👎.

Four findings, and the first is a repeat offence.

THE PROBE WAS KEYED ON THE IMMUTABLE PARAMETER. `NodeSwitcherViewModel.switchTo`
repoints `apiClient.baseUrl` and `activeProfileId`; `nodeBaseUrl` is a CIRISApp
argument, fixed for the composition, and never moves. So after a switch the
probe did not re-run, and the per-node `remember(nodeUrl)` I added in
self-review never reset — meaning the staleness I thought I had closed was still
open, and one registry's registered/revoked verdict could be shown as the
selected node's answer.

This is the SECOND time I have reached for that parameter where the mutable
value was needed. The first was the reset home resolution, where I wrote a
comment claiming to pass the current URL while passing the stale one. Same
class, same file, days apart. Now derived from `activeProfile`, which the VM's
own documentation says is the screen's responsibility to react to.

A RETURNED HASH MUST BE THE HASH WE ASKED ABOUT. Rejecting only a blank one
still accepted a record for a different build and showed its verdict beside the
operator's query. Hashes are long and visually similar, so rendering the
returned value is not enough to catch it — the mismatch is now Unavailable.

THE PROBE WAS ONE-SHOT. UNREACHABLE and UNDETERMINED are both transient, the
copy tells the operator to try again shortly, and nothing could: the effect was
keyed only on a URL that does not change, so a node that recovered stayed
unusable for the session. There is a "Check again" now, offered ONLY for the two
transient states — a retry on ABSENT or UNDECLARED invites pressing a button
that cannot change the answer.

BACK WENT TO AN AGENT SURFACE. Verify is opened from Manage Nodes, and both back
paths sent the operator to Interact — which the node-mode gate removes from the
sidebar, so a bare-node user exited onto a screen their node cannot serve. Both
go back to Manage Nodes.

Refs CIRISServer#499.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 6e2ad9eba5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +828 to +829
val effectiveNodeUrl = nodeSwitcherViewModel.activeProfile
?.baseUrl?.takeIf { it.isNotBlank() } ?: nodeBaseUrl

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve the configured node until a real switch

In WASM, where Main.kt passes the browser origin, and on desktop with CIRIS_NODE_URL, NodeSwitcherViewModel.reload() initializes activeProfile to its hard-coded http://127.0.0.1:4243 local profile; this expression then replaces the configured nodeBaseUrl even though no switch occurred. The capability probe and lookup consequently target localhost rather than the attached remote node, making verification unavailable in browsers or potentially attributing a different local registry's verdict to the configured node. Fresh evidence after the active-profile fix is that the initial profile itself is synthesized with LOCAL_NODE_URL, so only use it after an explicit switch or initialize it from the configured node.

Useful? React with 👍 / 👎.

"wallet_trust_degraded": "Hardware Trust Degraded",
"wallet_warning": "Warning"
"wallet_warning": "Warning",
"verify_title": "Verify a build",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Translate the verification keys for every shipped locale

Fresh evidence after replacing the hard-coded UI copy is that these 21 mobile.verify_* keys were added only to English: the localization checker reports them missing from each of the 28 non-English bundles, and LocalizationManager therefore falls back to English for the entire verification flow. Run these keys through the translation/evaluation/repair pipeline and update every mirrored locale before shipping rather than silently demoting non-English users.

AGENTS.md reference: AGENTS.md:L17-L17

Useful? React with 👍 / 👎.

Comment on lines +111 to +113
if (onRetry != null &&
(state == CapabilityState.UNREACHABLE || state == CapabilityState.UNDETERMINED)
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow capability refresh after a node upgrade

After an operator upgrades the attached node or installs registry support at the same URL, an UNDECLARED or ABSENT result can change to PRESENT, but this condition deliberately withholds the only retry control for both states. Because the probe effect in CIRISApp is keyed only by URL/profile/attempt and its state survives leaving and reopening Verify, the form remains unavailable for the rest of the app session even after the node gains the capability; expose refresh for these states or re-probe on reconnect/screen entry.

Useful? React with 👍 / 👎.

Comment on lines +249 to +251
OutlinedTextField(
value = hash,
onValueChange = { hash = it; result = null },

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Discard a lookup result when the hash changes

While a lookup is in flight the text field remains editable, so a user can submit hash A and then replace it with hash B; onValueChange clears the old result, but A's coroutine later writes its result back underneath the field now showing B. The API's returned-hash validation does not prevent this because the response correctly matches A. Capture the submitted hash and ignore the completion if the field has changed, cancel the old job, or disable editing until it finishes.

Useful? React with 👍 / 👎.

emooreatx and others added 2 commits August 28, 2026 19:21
THE ACTIVE-PROFILE FIX BROKE THE CONFIGURED NODE. `NodeSwitcherViewModel.reload()`
synthesizes an initial profile hard-coded to 127.0.0.1:4243, so preferring
`activeProfile` outright replaced the CONFIGURED node — the browser origin on
wasm, CIRIS_NODE_URL on desktop — with localhost when nobody had switched
anything. Verification became unavailable in a browser, and a local registry's
verdict could be attributed to a remote node (Codex, PR #20).

The three revisions read as one lesson:

    first   nodeBaseUrl     a parameter that never changes  -> stale
    second  activeProfile   a default nobody chose          -> wrong
    now     activeProfile AFTER an explicit switch, else nodeBaseUrl

Over-correction is its own failure mode, and this is what it looks like: I fixed
staleness by reaching for the other value in scope without asking whether it
meant what I needed. The first profile id is captured on entry and the profile
preferred only once the active id has moved off it.

RETRY FOR EVERY NON-PRESENT STATE. I withheld it from ABSENT and UNDECLARED,
reasoning that pressing again cannot change a settled answer. It can: an
operator upgrades the node or installs registry support at the SAME URL, and the
probe result survives leaving and reopening the screen — so the form stayed
unavailable for the rest of the session on a node that had just gained the
capability.

A RESULT MUST BELONG TO THE HASH THAT WAS SUBMITTED. The field stays editable
while a lookup runs, so submitting A and typing B left A's coroutine to write
its answer under a field showing B. The returned-hash check cannot catch that —
the response correctly matches A. The submitted value is captured and the
completion discarded if the field has moved on.

Translations for the 21 verify ids remain outstanding and are the CI blocker.

Refs CIRISServer#499.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
Yoruba rejected `verify_absent_body` for clumsy, redundant phrasing, and the
English earns it: "Build verification is part of the registry, and this node
does not carry it" puts two nominalizations in front of the reader before any
concrete claim, and Yoruba has to render both as compounds.

    This node does not carry the registry, so it cannot check builds.
    Another node can.

One concrete clause, then one. Fourth time this session the translation blocker
was English that a reader could parse and a translator could not commit to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7acd0e7fc7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +839 to +843
val switched = activeProfileId != null && activeProfileId != firstProfileId.value
val effectiveNodeUrl = if (switched) {
nodeSwitcherViewModel.activeProfile?.baseUrl?.takeIf { it.isNotBlank() } ?: nodeBaseUrl
} else {
nodeBaseUrl

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor switches back to the initial profile

When a configured remote session has an initial synthetic local profile, switching to another node and then back to that first profile makes activeProfileId == firstProfileId, so this branch treats the real switch as if no switch occurred and restores the startup nodeBaseUrl. NodeSwitcherViewModel.switchTo has already repointed apiClient to the selected profile, but the capability probe and registry lookup now query the original node instead, potentially attributing its verification or revocation verdict to the selected local node. Fresh evidence after the configured-node fix is that returning to the first profile ID is indistinguishable here from never having switched; track whether a switch occurred rather than inferring it from ID equality.

Useful? React with 👍 / 👎.

emooreatx and others added 2 commits August 28, 2026 19:33
Switching away from the initial profile and back to it made
`activeProfileId == firstProfileId` again, so the branch read a REAL switch as
"never switched" and restored the startup URL — while `switchTo` had already
repointed `apiClient` at the profile the operator chose. The probe and lookup
then queried the original node and could attribute its verdict to the selected
one (Codex, PR #20).

Choosing the local profile deliberately is a choice, and it has to be honoured
as one. `hasSwitchedNode` latches on the first real change and stays true: after
that the operator owns the selection, including when the selection is the
profile they started on.

Fourth revision of this one expression, and each was the same mistake in a
different coat — reading state instead of observing the event that changed it:

    nodeBaseUrl                     a parameter that never changes
    activeProfile                   a default nobody chose
    activeProfileId != first        an event inferred from a value
    hasSwitchedNode                 the event

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
…ators

Two rejections, both legitimately translation-side this time — and both point at
the same gap.

  yo  "Deprecated"      rendered as "abandoned/left"; the reviewer says UI
                        Yoruba wants "no longer recommended / to be phased out"
  am  "Do not run it."  rendered as "do not OPEN it" — executing and opening
                        are different acts, and this warning is about executing

"Deprecated" is a term of art and it is NOT in the glossaries: `[DEPRECATED]` in
there is my pipeline's retirement marker, not the status. The apparent fix is to
add a row to all 29 — which means coining a rendering in 28 languages I do not
speak, exactly what TRANSLATION_GUIDE.md §3 forbids and what the glossaries
exist to prevent.

So say the thing instead of the term:

    Deprecated             ->  No longer recommended
    ... Do not run it.     ->  ... Do not use it.

The first is what the Yoruba reviewer said the word should mean, which makes it
a better English label as well — the registry's own token still appears verbatim
for statuses this client cannot map, so fidelity is not lost. The second drops a
run/open distinction that English carries in one word and many languages split.

Fifth time this session, and the rule has held every time: a word that needs a
footnote in one language needs one in twenty-eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Three repos now have an opinion about what silence means on a capability list —
CIRISServer declares conferred scopes in `src/conformance.rs`, CIRISAgent is
proposing an agent-tier field on `/v1/system/health`, and this client renders
both. `CapabilityWire` is the single reader, written so the other two can read
it instead of coining a third interpretation.

FOUR WIRE SHAPES, ENUMERATED RATHER THAN INFERRED:

    field absent        UNDECLARED     an older peer — upgrade it
    "field": null       UNDETERMINED   it could not read its own record — retry
    "field": []         ABSENT         it read, and holds nothing — another peer
    "field": [ ... ]    membership     proceed

Four facts, four remedies. Collapsing any pair produces a confident wrong answer
instead of a missing one, and this reader's first version collapsed two: it
matched only the array form, so `null` read as a missing field and the UI told a
CURRENT node's operator to upgrade. CIRISServer's own field comment is what
caught it, and it is quoted in the source here so the reason travels with the
rule.

A fifth state, UNREACHABLE, is OUR side failing to ask — transport, a non-success
status, a body that will not parse. It never arrives from the wire.

REAL JSON, NOT A REGEX. The previous reader pattern-matched the document, which
is how a list carrying a non-string would have been silently narrowed to the
entries it could read — reporting a SMALLER declaration than the peer made,
which is a confident wrong answer about authority. Anything unreadable is now
UNREACHABLE, including a partially readable list.

PROVENANCE IS NOT MERGED. `parse` takes the field NAME because conferred scopes
and agent features are different authorities: one is signed by the trust root
and enforced by the node, the other is a property of the running brain that
nothing attests. CIRISServer refuses that laundering at its own tier — "a
locally-detected capability is a different authority and is not laundered
through this list" — and a union at the agent tier would be the same act one
floor up. FIELD_AGENT is named distinctly for the same reason: `/v1/system/health`
is the node's health merged with the brain's, so a bare `capabilities` there
could not be attributed to either tier by a reader holding only the parsed set.

The test file is the specification: every shape, the state it yields, and the
remedy that state implies. A shape the other repos emit that is not listed there
is the gap to close.

Refs CIRISServer#499.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

Seven runs on this branch. Each one translated 21 keys into 28 languages, each
one had exactly ONE pair rejected — a different pair every time — and each one
threw away the other 587 and cost a full lane to do it. That is the entire
reason this branch has been stuck, and it was avoidable from the second run.

WHAT WAS HAPPENING

`localize.py` wrote every value, accepted and rejected alike, then exited 1. The
job failed at that step, so Guard, digest and Commit never ran, and the runner
was torn down with all the good translations still on its disk. Nothing was
banked. The next run started from zero and rolled the dice again.

WHAT CHANGES

  localize.py   writes ONLY accepted values; rejected and unresolved keys are
                withheld, not written
  the workflow  runs Guard, digest and Commit with always(), so the accepted
                values are committed even though the lane exited 1

WHAT DOES NOT CHANGE — and this is why it is safe:

- A rejected value is still never written. Nothing semantically bad reaches a
  bundle, and English still never appears under a non-English locale.
- The run still exits 1. It is still red, still visible, still fails the branch.
- The withheld key stays MISSING, so the strict guard still blocks the merge
  until it is filled — the same block, from the same gate, for the same reason.

The only thing that changes is that a run keeps what it earned. The next run has
one key to redo instead of 588.

I raised this twice as "a change to a guarantee you specified" and deferred it
both times. That framing was wrong: the guarantee is that rejected translations
do not ship, and withholding them honours it more exactly than writing them and
discarding the run did. I should have fixed it at the second failure instead of
re-rolling five more times.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

github-actions Bot and others added 3 commits August 29, 2026 01:35
Machine translation, independently reviewed against MQM, and repaired
where the review found a critical, major or terminology error. Every
value here is status=draft / review_status=needs_native_review: this
pipeline guarantees terminology, structure and meaning, and does not
guarantee native fluency.

Validated by check_localization_sync.py --strict in this same run.
The MQM findings are attached to the run as i18n-report.json.
Review like any other diff.
Machine translation, independently reviewed against MQM, and repaired
where the review found a critical, major or terminology error. Every
value here is status=draft / review_status=needs_native_review: this
pipeline guarantees terminology, structure and meaning, and does not
guarantee native fluency.

Validated by check_localization_sync.py --strict in this same run.
The MQM findings are attached to the run as i18n-report.json.
Review like any other diff.
…n the last language

    unresolved[lang] = could_not                     line 1012  accumulator, lang -> keys
    fixes, unresolved = repair_until_clean(...)      line 1038  REBOUND per language
    for lang, keys in sorted(unresolved.items())     line 1108  AttributeError

The per-language result shadowed the accumulator, so after the loop
`unresolved` was whatever the LAST language returned. When that was empty the
final report worked by accident; when it was not, the run died with
`'list' object has no attribute 'items'` — and because it died there, the
accumulator's contents were never reported at all.

Pre-existing, and it survived because full-set runs happened to end on a
language with nothing outstanding. Scoping a run to `de my` put a failing
language last and it fell straight over. The banking change is what made scoped
runs worth doing, so it did not cause this — it made it reachable.

The per-language binding is `unrepaired` now, and the guard above it tests the
same thing its body reports; it was testing the accumulator and printing the
per-language count, which would have printed a rejection line for a language
that had none.

Verified by AST: `unresolved` is bound once in `run`, `unrepaired` is the
tuple-unpacked per-language value.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

github-actions Bot and others added 2 commits August 29, 2026 01:46
Machine translation, independently reviewed against MQM, and repaired
where the review found a critical, major or terminology error. Every
value here is status=draft / review_status=needs_native_review: this
pipeline guarantees terminology, structure and meaning, and does not
guarantee native fluency.

Validated by check_localization_sync.py --strict in this same run.
The MQM findings are attached to the run as i18n-report.json.
Review like any other diff.
The last run did all its work and then failed:

    ! [rejected]  feat/portal-capability-gate (fetch first)
    error: failed to push some refs

A lane takes minutes, and a human can land a commit on the same branch while it
runs — which is precisely what happened: I pushed a fix mid-run, the runner
finished, committed the last translation, and could not push it. Everything
before that step succeeded, so the run reported failure for a race rather than a
defect, and the work was only safe because a previous run had already landed it.

Rebase and retry, three times. A conflict is left to fail loudly: the bundles
are regenerated deterministically from en.json, so a real conflict means someone
edited the same keys and resolving it blind would silently pick a winner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@emooreatx
emooreatx merged commit 57be605 into main Aug 29, 2026
7 checks passed
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