Skip to content

Let a managed deployment bind somewhere other than loopback - #24

Merged
jmc-wander merged 8 commits into
mainfrom
feature/kinbase-allow-non-loopback
Sep 24, 2026
Merged

jmc-wander merged 8 commits into
mainfrom
feature/kinbase-allow-non-loopback

Conversation

@shanebarakat

@shanebarakat shanebarakat commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

The proof binds loopback so a laptop store cannot be reached by anyone but its operator. A deployment behind a cluster Service takes that isolation from the network policy instead, and today it cannot start at all.

Three gates enforce loopback, each labelled "in this proof":

gate where
bind address config.rs:865
Host header, every request server.rs:278
client company.url config.rs:625

A sidecar proxy fixes the first two and cannot fix the third, because that one lives in every client’s own config check.

What this does

One opt-out key, allow_non_loopback, default off, so nothing changes shape for an existing config.

The bind and Host checks move together because they have to: enforcing Host against loopback on a routable bind refuses every request. The client flag is a separate key in a separate file, and a URL passed to repo issue --company is now held to the operator’s configured setting rather than to the argument.

Tests

tests/service_bind_scope.rs covers the property that matters, which is that the opt-out stays opt-out:

  • a config that does not mention the key still refuses a routable bind
  • allow_non_loopback = false is the same as leaving it out
  • a routable bind is accepted only when the key asks for it
  • a loopback bind needs no key

Full suite: 215 passed, 0 failed. Clippy: 671 warnings before and after, none added. Positive control: flipping the default to true fails exactly the "still refuses" test; reverting passes it.

Verified by hand as well — served on 0.0.0.0:8422 with the flag, and a request with Host: kinbase.kinbase.svc.cluster.local returned 401 rather than 403, i.e. it reached authentication instead of being refused at the Host check.

Why it is safe to relax

The root key signs only on authenticated fact-event write paths. GET /snapshot does not sign and exposes only the public key. Every route still requires a scoped token, a bound client key, a fresh nonce, an expiry and a request signature. Reaching the port is a confidentiality and DoS surface, not an integrity one.

Part of a set

  • This PR — lifts the restriction. Blocks the one below.
  • wandercom/kinbase-live#1 — the Kubernetes deployment. Its image pin is the commit before this one, so it waits on this merging.
  • Add two read-only Kinbase tools to the MCP server kindex#62 — read-only Kinbase tools on the MCP server. Independent of both; reviewable in parallel.

Addressed from review

A fourth gate existed and I had missed it. http::parse_url validates the endpoint again on every request, so the client flag passed config validation and was then refused at the socket — configured for a routable store, and reported as unreachable. parse_url now takes the same setting, threaded through Client::new and its three call sites. deliver_to_channel keeps passing false: an authority channel is loopback by its own rule, not the endpoint's.

A present non-boolean is now malformed configuration, not a false. allow_non_loopback = "true" previously read as off, refusing the bind while the file said otherwise. optional_bool rejects it with the section and key named.

A real non-loopback client request is covered. repo issue is the shortest path that actually builds a client, so the test drives that rather than status, which returns on an uncertified repository before reaching the transport.

Worth saying how that last one was found: the first version of the test passed with the fix and with the fix reverted. It was vacuous. The positive control caught it, and the fixture needed an admin_token_file before repo issue would get as far as constructing a client at all.

Now 218 tests pass, 7 in this file. Both new behaviours have positive controls: reverting the transport gate fails the client test, and restoring the silent coercion fails the boolean test.

@adaptcom adaptcom 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.

Confidence Score: 3/5

Summary

Adds opt-in remote access with bounded DNS/connect waits; previous findings are resolved. Two sandbox suite failures predate this revision, and Shane must verify deployment isolation before merging.

Important Files Changed

File Overview
crates/kinbase/src/company/client.rs Propagates endpoint permission; authority channels remain loopback-only.
crates/kinbase/src/company/mod.rs Passes service endpoint permission to steward publication.
crates/kinbase/src/company/server.rs Conditionally relaxes Host validation with the bind opt-out.
crates/kinbase/src/config.rs Adds default-off flags and rejects non-boolean values.
crates/kinbase/src/hooks.rs Propagates endpoint permission into the bounded connectivity probe.
crates/kinbase/src/http.rs Bounds DNS/connect waits and tests timeout behavior with deliberately slow work.
crates/kinbase/src/launcher.rs Propagates configured permission to clients and port lookup.
crates/kinbase/src/repository.rs Applies configured endpoint permission to certificate issuance.
crates/kinbase/tests/registry_publication.rs Preserves loopback-only behavior in publication fixtures.
crates/kinbase/tests/service_bind_scope.rs Checks network failure, bind defaults, and boolean type rejection.

↻ Re-run review · View in Adapt

.get("allow_non_loopback")
.and_then(toml::Value::as_bool)
.unwrap_or(false);
validate_loopback_url(&url, allow_non_loopback)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With allow_non_loopback=true, Client::new still rejects remote URLs in http::parse_url; propagate the configured flag through transport callers and cover a real non-loopback client request.

Comment thread crates/kinbase/src/config.rs Outdated
// policy instead, so the guard is opt-out and stays on by default.
let allow_non_loopback = table
.get("allow_non_loopback")
.and_then(toml::Value::as_bool)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Both parsers silently treat allow_non_loopback="true" or 1 as false; reject present non-boolean values with CONFIG_INVARIANT instead of accepting malformed configuration.

@shanebarakat
shanebarakat force-pushed the feature/kinbase-allow-non-loopback branch 2 times, most recently from f613a4e to 92db7f0 Compare September 23, 2026 02:48
The proof binds loopback so a laptop store cannot be reached by anyone but its
operator. A deployment behind a cluster Service takes that isolation from the
network policy instead, and today it cannot start at all: the bind address, the
Host header and the client's company.url are each required to be loopback.

One opt-out, `allow_non_loopback`, default off, so nothing changes shape for an
existing config. The bind and Host checks move together because they have to:
enforcing Host against loopback on a routable bind refuses every request. The
client flag is its own key in its own file, and a URL passed on the command line
is now held to the operator's configured setting rather than to the argument.

Tests cover the part that matters, which is that the opt-out stays opt-out: a
config that does not mention the key still refuses a routable bind, and setting
it false is the same as leaving it out.
@shanebarakat
shanebarakat force-pushed the feature/kinbase-allow-non-loopback branch from 92db7f0 to 53ec266 Compare September 23, 2026 14:57
Allowing a Company endpoint to be a name rather than a loopback address moved
work in front of the connection budget without extending it. `to_socket_addrs`
blocks with no bound of its own, so a slow resolver pushed SessionStart past its
own two-second budget while every message still said 250 ms.

Resolution now runs on its own thread and the deadline covers both halves, with
whatever remains handed to the connection. A resolver that answers late finds
nobody listening; nothing is cancelled because the standard library offers no
way to.

Measured on the deployment host: an unresolvable name answers in 390-418 ms and
an address that accepts nothing in 621 ms, against 465 ms for a working
loopback endpoint.
The resolver tests only prove the bound on a machine whose resolver is slow
enough to notice, so all three passed with the timeout removed. The bounded
wait now lives in `within`, on its own, and is tested against work that sleeps
five seconds against a fifty millisecond budget: unambiguous anywhere, and it
fails in exactly five seconds when the bound is taken away.
@jmc-wander

Copy link
Copy Markdown
Contributor

Independent review. I traced the changed lines rather than reading the description, so let me lead with the thing I went in expecting to find and did not.

The default is genuinely unchanged, and you proved it the right way

optional_bool returns Ok(false) for a missing key (config.rs:624-636), config.rs:900-905 gates on !allow_non_loopback, load_service_config is the only non-test constructor, and there is no Default impl and no env override. An existing deployment binds loopback, byte for byte. The key is in both strict allowlists (config.rs:395, config.rs:861), so an old binary reading a new config fails closed on the unknown key instead of ignoring it.

Three things here are better than the usual version of this change:

  • the_flag_set_false_is_the_same_as_absent tests the equivalence, not just the refusal. That is the test that actually pins the default.
  • optional_bool refusing a quoted "true" (config.rs:619-636) is the standout call. The common bug is serde-style leniency reading a malformed value as false, which refuses the bind while the config file says otherwise. Failing closed and loudly is harder and correct.
  • a_routable_company_url_reaches_the_socket_when_the_flag_asks_for_it asserts COMPANY_UNREACHABLE against TEST-NET-1 rather than merely the absence of a loopback error. A test that only says "no loopback error" passes for the wrong reasons too, and the comment says so.

The third commit is the best thing in the PR: you re-ran the resolver tests with the timeout removed, found all three still passed, said so in the commit message, and wrote one that fails in exactly five seconds when the bound is gone. That is a mutation test on your own guard, unprompted.

Blocking

1. The spec is contradicted, not amended. spec/cli.md:50 ("loopback only in the PoC"), spec/verification.md:785 ("non-loopback Host" in the reject list), spec/architecture.md:482 ("exact loopback Host validation"). REVIEW.md: "A change that contradicts a spec passage is a spec defect to raise, not a deviation to ship; cite the section in the commit body." No spec file is touched and no commit carries a Spec: trailer. tests/acceptance/_harness/service.py:244-266 (assert_loopback_only) cites spec/cli.md by name as pinning the bind — that gate still fires for the default path, but it now enforces a rule with an undocumented opt-out.

2. On a routable bind the bearer token is the whole credential, and it crosses the network in cleartext. http.rs:305-307 strips http:// and rejects https:// outright, so TLS is not merely absent, it is unreachable. client.rs:80-83 sends Authorization: Bearer on every request.

The signature does not save this: authenticate (server.rs:519-668) accepts any X-Kinbase-Client-Key the caller supplies; record_token_client_pair only records the pair (its own doc says "Several keys may use one token"); and bind_token_key (db.rs:403) — the first-use binding that would fix exactly this — has no callers anywhere in the tree.

Concrete: with bind = "0.0.0.0:8421" and clients pointed at the Service name, anyone who observes one request (a co-scheduled pod with CAP_NET_RAW, a node tap, an egress logger) lifts the token from the cleartext header, generates a fresh ed25519 keypair, signs a receipt over GET /facts with a new nonce, and reads every classified company fact in that token's scopes. With the admin token, POST /tokens and POST /certificates. Nonce replay protection is irrelevant — they are not replaying, they are minting.

If NetworkPolicy is the intended isolation, that is a defensible position, but it should be written down as an accepted risk rather than inherited silently — and the PR offers no transport option for operators who want defence in depth.

3. Pre-authentication work on an open port: SQLite writes and root-key signing. server.rs:312 (CompanyDb::open) and server.rs:314 (maintenance) both run before the /status auth short-circuit (server.rs:316-325) and before authenticate (server.rs:326). maintenance (server.rs:3726) calls prune_nonces, expire_manifest_observations, and state.root.sign_document("fact-event", ...) — so an unauthenticated request drives SQLite writes and Company root key signatures.

Compounding it: server.rs:230-234 spawns an unbounded thread per accepted connection, each held up to the 5s read timeout (server.rs:239-240) before any credential is looked at. The throttles at server.rs:550 and server.rs:655 live inside authenticate, downstream of all of it. No accept cap, no per-peer connection limit. 20k one-byte connections stops the company memory store answering its real clients. On loopback the peer set was the operator's own processes and this was a non-question.

Non-blocking

4. The Host check is deleted, not relaxed. server.rs:276: if !host_is_loopback(host) && !state.config.allow_non_loopback. Once opted in, any Host value is accepted. Your own commit names the correct replacement ("the Host is the Service name") and then does not check it. One flag now owns two facts: where may I bind and what Host will I answer to.

I tried hard to build a rebinding exploit and failed — request.header("host") is read at exactly one site (server.rs:272), never cached or reflected into absolute URLs; a rebound browser cannot forge X-Kinbase-Signature; and Origin-bearing requests are refused at server.rs:286. So this survives as a design finding, not a vulnerability. But an advertised_host the deployment already knows was available and cheaper than "no check".

5. Two owners for one fact, unreconciled. config.rs:37 (CompanyConfig.allow_non_loopback, doc: "Mirrors the service flag") and config.rs:821 (ServiceConfig.allow_non_loopback), with nothing detecting disagreement. Service on + client off: every client refuses with "company.url must name a loopback endpoint..." against a service running perfectly. Client on + service off: the client will carry the admin token to any routable URL and only the absent listener stops it.

The client-side flag looks redundant. company.url already is the authority for where the endpoint is, and repository.rs:2145-2151 already refuses any --company that differs from it. Deriving the client rule from the configured URL alone removes a second thing to get wrong and a second switch for whoever can write the user config.

6. The 250ms budget now has to cover DNS and TCP. http.rs:378-417, :445. Before, resolution was unbounded but free (a loopback literal) and connect got the full CONNECT_BUDGET; now resolve_within spends part of it and hands remaining to connect_timeout. The only shape that resolves a name is the managed one this PR exists for. CoreDNS on a cold cache, after a restart, or with ndots=5 search-path expansion can spend 180ms, leaving 70ms to connect — and the result is COMPANY_UNREACHABLE, an outage-shaped error for a healthy service. The budget was sized when resolution cost nothing and was not revisited. (Nit: http.rs:428 uses PROBE_BUDGET = 200ms but the message still says 250ms — pre-existing, more confusing now.)

7. First resolved address only, no fallback. http.rs:384-386: .map(|mut addresses| addresses.next()). Pre-existing shape, but previously the iterator had exactly one element by construction. On a dual-stack cluster a Service name can return AAAA first; a pod with no working IPv6 route gets COMPANY_UNREACHABLE on every request against a healthy service, with no attempt at the next address — in exactly the deployment this PR exists to serve.

8. Minor. http.rs:363 — recv_timeout(budget).ok() collapses Timeout and Disconnected into None, and None is reported as "did not resolve within the N ms budget", so a panicking worker reads as a slow resolver; thread::spawn also panics rather than returning a typed ContractError, against REVIEW.md's typed-errors rule. http.rs:313-321 — with the flag on, http://:8421 (empty host) passes validation and fails later as COMPANY_UNREACHABLE rather than as a config error. client.rs:327-331 — deliver_to_channel is hard-wired false and has no in-tree callers, so it is inert, but it is the one path that cannot follow the operator's setting.

Things I checked and could not make stick

--company <url> widening the rule (refused at repository.rs:2145; the flag is read from config, never argv); IPv6 bind breaking parse_url (brackets stripped at http.rs:313); company publish against 0.0.0.0 (resolves to localhost on both Linux and macOS); an old binary silently ignoring the new key (the strict allowlist fails it closed).

Verdict

CHANGES_REQUESTED — narrowly. The default path is provably unchanged and the mechanism is well built. The blocking asks are: amend the spec (or get the governance sign-off) before merge, and state in the PR — or fix — the cleartext-token and pre-auth-work exposures the opt-in creates. 4-8 are comments, not blockers.

The bind, Host and client endpoint rules were lifted behind one opt-out key
without amending the three passages that state them, so the code and the spec
disagreed and the reader had no way to know which was current. REVIEW.md asks
for the passage to be amended or the defect raised, not for the deviation to
ship quietly.

Also records what the opt-in costs, which was inherited silently before. On a
routable bind the transport is cleartext: the endpoint parser accepts http://
only, so TLS is unreachable rather than absent, and the bearer token crosses the
network on every request. The request signature does not narrow this, because a
caller supplies its own client key and the first-use binding that would pin a
token to a key is recorded but never enforced. Isolation comes from the network
rather than from the service, which is defensible for a managed deployment and
is now written down as an accepted risk with the two open items named.

Spec: spec/cli.md bind address, spec/architecture.md Host validation and
      credential model, spec/verification.md HTTP rejection list
Opening the database and running due maintenance both happened on arrival,
ahead of the status short-circuit and ahead of authenticate. Maintenance prunes
nonces, retires lapsed manifest observations and signs fact-events with the
Company root key, so connecting was enough to make the service write SQLite and
sign with its root key. On loopback the peer set was the operator's own
processes and this did not arise. On a routable bind it is anyone who reaches
the port.

Maintenance now runs immediately after authenticate, so the same obligations are
paid by a caller that proved a credential, and no handler can observe a stale
observation the old order would have retired. Opening the handle stays in front
because authenticate needs it to read the token and record a failure, and a
SQLite open is a read, not a write.

The accept loop also spawned a thread per connection with no ceiling, each held
up to the five second read timeout before any credential was looked at, so
silent connections could starve real clients long before the throttles inside
authenticate were reached. Connections now claim a place under a ceiling of 64
and give it back on drop; over the ceiling the peer gets the typed 503 refusal
rather than a thread.

Spec: spec/architecture.md Host validation and credential model
The connect budget was sized when company.url was always a loopback literal and
resolution cost nothing. A cluster Service name is the shape this opt-out exists
for, and resolution now spent part of that budget before connect saw it: CoreDNS
on a cold cache or with ndots search-path expansion can take most of it, leaving
a healthy service to fail as COMPANY_UNREACHABLE. Resolution gets its own budget
and the connect budget goes back to meaning only the connection, which leaves
the ratified number alone.

Only the first resolved address was tried. A dual-stack Service name can return
AAAA first, so a pod with no working IPv6 route failed every request against a
healthy service. Every address is now tried in order inside one deadline.

A dead resolver thread and a spent budget both arrived as None and were both
reported as a spent budget, so a thread that died read as a slow network. They
are now distinct, a thread the OS refuses returns a typed error rather than
panicking, and a URL naming no host is refused as configuration instead of
surfacing later as an unreachable endpoint.

Spec: spec/architecture.md connection budget
@shanebarakat

Copy link
Copy Markdown
Contributor Author

All three blockers addressed:

Spec amended across cli.md, verification.md and architecture.md, with a Spec: trailer.

Cleartext token recorded as accepted risk rather than inherited silently, including the part the spec had wrong: it claims tokens are "bound to a client-instance public key," but bind_token_key has no callers, so that isn't enforced. The chart already fails closed here too, networkpolicy.yaml refuses to render if allowedNamespaces is set without transport.mtls=true.

Pre-auth work: maintenance moved behind authenticate. CompanyDb::open stays in front because authenticate needs the handle and a SQLite open is a read. Added a 64-connection ceiling, over which peers get the typed 503 rather than a thread.

6, 7 and 8 are fixed. Holding 4 and 5 as follow-ups rather than expanding a diff you'd already traced, though I agree the client-side flag is the redundant one. Say the word if you'd rather they land here.

@jmc-wander

Copy link
Copy Markdown
Contributor

Re-reviewed at head 0b27df0. All three blockers are substantively discharged. One new finding is holding my merge, and it's a two-line fix.

The blockers

Spec contradiction — fixed. 021850a amends all three passages with a Spec: trailer naming them: spec/cli.md:50 now reads "loopback unless allow_non_loopback is set"; spec/verification.md:785-788 moves non-loopback Host off the unconditional reject list; spec/architecture.md:482 likewise. And spec/architecture.md:501-518 is a new passage recording the cleartext-bearer consequence as accepted risk with both open items named. That's the governance answer, done properly.

Cleartext bearer token — accepted, not remediated. No code changed: http.rs:308-309 still rejects https://, client.rs:85 still sends Bearer every request, db.rs:403 bind_token_key still has zero callers (git grep returns only the definition), server.rs:641-700 still accepts any parseable x-kinbase-client-key with no lookup against a bound key, and server.rs:1094 hard-codes "client_key_bound": false. What changed is the record — spec/architecture.md:501-518 states plainly that "on a routable bind the token is therefore the whole credential" and that "an observer of one request can mint a fresh key and sign for the same token," with isolation coming from the network. That was the third remedy I offered, so I'm treating the blocker as discharged by explicit acceptance. Worth being clear it's acceptance, not a fix.

Pre-auth work on an open port — mostly fixed, and the test is the good part. server.rs:393 moves maintenance after authenticate at :392, so root-key signing is behind the credential. MAX_CONCURRENT_CONNECTIONS = 64 with an atomic ConnectionSlot claimed on the accept thread — correctly, before the worker exists — released on Drop, and refuse_over_ceiling writing a typed 503 on a non-blocking socket (server.rs:33, 39-60, 268-283, 288-297).

tests/unauthenticated_pressure.rs is a real forcing test: it plants a lapsed observation, hits /status, /facts, /events unauthenticated, asserts 401 with unchanged event_count and the observation still "current" — then makes one authenticated read and asserts the obligation is paid (status → "historical", event count rises). It proves the work was deferred, not deleted, which is the distinction that matters. I ran it; it passes, 9/9 with service_bind_scope.

Residual: 64 is a single process-global counter with no per-peer limit, so one peer can still take every slot and starve real clients for the 5 s read timeout. The contended resource moved from threads to slots; the denial didn't go away.

What's holding the merge

0b27df0 carries Spec: spec/architecture.md connection budget and amends no spec file. git show --stat is http.rs only, 261 insertions. I verified the consequence directly at your head:

crates/kinbase/src/http.rs:19   CONNECT_BUDGET = 250ms
crates/kinbase/src/http.rs:364  RESOLVE_BUDGET = 250ms     # now in series
spec/architecture.md:764   "...asynchronous Company refresh with a 250-millisecond connection budget"
spec/verification.md:495   "...budget is 250 ms, cold/degraded projection is empty..."

Worst-case Company refresh is now 500 ms; both spec passages still say 250 ms; neither is in this PR's spec diff.

This is the identical defect class 021850a exists to fix — code deviating from an unamended passage — and the trailer makes the record look discharged when it isn't. I tried twice to kill it: the refresh is asynchronous and the 2 s SessionStart p95 still holds (true, which is why this isn't severity-high), and "connection budget" might mean TCP-connect only — but architecture.md:763 attaches the number to the refresh, and verification.md:495 sits inside a latency-measurement obligation, so anyone measuring either now sees up to 500 ms.

I'm holding rather than merging because this PR's own thesis is that code and spec must not silently disagree, and you demonstrated that standard yourself in the first commit. Amend the two passages (or split the budget language so "connection budget" means the connect half) and I'll merge on sight — it's one commit.

Also worth a look

a9bc775's message asserts "a SQLite open is a read, not a write." It isn't. CompanyDb::open (db.rs:44-70) runs PRAGMA journal_mode=WAL (:61), set_permissions(0o600) (:66) and migrate() (:74), which executes two INSERT OR IGNORE INTO meta(...) (:164, :170) on every open — two write-transaction acquisitions per unauthenticated connection against a 10 s busy_timeout. The unauthenticated /status short-circuit also calls db.record_auth_failure (server.rs:381) pre-auth.

In practice it's fine — WAL lets readers proceed, INSERT OR IGNORE on an existing key dirties no page, the DDL is a no-op, the ceiling bounds it, and authenticate genuinely needs the DB. But the justification in the commit message is wrong, and the fix is cheap: hoist migrate() to startup (the launcher already opens the DB at server.rs:217) so the request path is a plain connection.

From the first review

Addressed: the 250 ms budget covering DNS+TCP (http.rs:364, separate budget) and first-resolved-address-only (http.rs:463-481 — connect_within now iterates every address under one deadline, resolve_within returns Vec<SocketAddr>), plus a distinguished Abandoned::Budget vs Abandoned::Worker and an empty-host refusal at :316-321. That's a clean answer to both.

Not addressed: Host is still entirely unchecked once allow_non_loopback is set, never validated against a known Service name (server.rs:335); and allow_non_loopback still exists in both [company] (config.rs:39/150) and [service] (config.rs:823) with nothing detecting disagreement.

One commit from a merge.

Giving resolution its own budget put two 250 ms budgets in series, so a refresh
against a named endpoint is bounded at 500 ms rather than 250. Two passages
still stated the old number: architecture.md attaches it to the refresh, and
verification.md sits inside a latency obligation, so anyone measuring either
would have measured against a bound the code no longer holds to.

The previous commit carried a Spec: trailer naming the connection budget and
amended no spec file, which made the record look discharged when it was not.
That is the same defect the first commit in this branch exists to fix.

Spec: spec/architecture.md SessionStart refresh budget,
      spec/verification.md Company connect budget
An earlier commit here claimed a SQLite open is a read. It is not. CompanyDb::open
sets the file mode and runs migrate, and migrate is two INSERT OR IGNORE statements
against the meta table, so every connection took two write transactions against a
ten second busy timeout before any credential was looked at. Moving maintenance
behind authenticate removed the root-key signing but left this.

open keeps the startup behaviour and is what the launcher and the tests use.
connect is what a request uses: it opens, sets the busy timeout and the pragmas,
and writes nothing. The symlink check stays on both, because a file swapped
underneath a running service is what it exists to catch.

Both are pinned by tests that fail without the change: open against a directory
that does not exist yet, which is the ordering a first install depends on, and
connect against a fresh file, which must come back with no schema.
@jmc-wander

Copy link
Copy Markdown
Contributor

Verified both at 2daf561/49981fa and merging.

spec/architecture.md:763-768 now names the series explicitly — "a separate 250-millisecond resolution budget ahead of it, so a refresh that must resolve is bounded at 500 milliseconds" — and verification.md:495 matches. That's better than the amendment I asked for: it states the composition rather than just the new number, so the next person measuring either half knows which one they're looking at.

49981fa takes the schema migration off the request path; CompanyDb::open at server.rs:217 is now the startup call. Forcing tests pass on the head: an_unauthenticated_request_drives_no_root_key_signature and the_connection_ceiling_refuses_a_peer_that_sends_nothing.

Good work on this one — three blockers and two follow-ups in a day, and the unauthenticated_pressure test is the part I'd point other people at: asserting the obligation is paid on the first authenticated read, rather than just asserting the pre-auth work is gone, is what proves you deferred it instead of deleting it.

Two things left open deliberately, neither blocking, both worth a follow-up ticket: the Host is still entirely unchecked once allow_non_loopback is set (never validated against the known Service name, server.rs:335), and allow_non_loopback lives in both [company] and [service] config with nothing detecting disagreement. The connection ceiling is also process-global rather than per-peer, so one peer can still take all 64 slots.

@jmc-wander
jmc-wander merged commit 9fb7fa4 into main Sep 24, 2026
@jmc-wander
jmc-wander deleted the feature/kinbase-allow-non-loopback branch September 24, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants