Skip to content

fix(protocol): close credential revocation races and apply checkpoint-1 review fixes - #217

Closed
jsam wants to merge 19 commits into
mainfrom
fm/il-cp3
Closed

jsam wants to merge 19 commits into
mainfrom
fm/il-cp3

Conversation

@jsam

@jsam jsam commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Do not run CI redundantly; it is okay to move quickly and break things, then run QC at certain checkpoints. Call them release checkpoints, where the entire CI and QC verification runs. To move quickly, run and test things locally, make sure the relevant checks run in parallel before making the commit, and only run the checks that make sense, not everything. The performance policy should be done as a pipeline which is always run before a PR commit. Make sure the reactive expectations are part of the e2e quality checks pipeline. Always follow clean code principles and clean code architecture. After every big PR run QC agents that verify the issue or feature has been fully implemented or resolved. Documentation changes needed also have to always be kept up to date. Never put Claude/AI co-author or generated-with trailers in commits or PRs.

This change is the checkpoint-1 review fixes on top of main. Main already contains the release checkpoint 1 items (data-dir lock, SDK typed errors, credential revocation, capped subscriptions, recursive-read fix, perf gate, e2e reactive pipeline, GenBI benchmark, differential oracle, Verified Completions gate, transaction WAL) and now also API key expiry/rotation (#204); this change adds only the fixes their review required: the revoked-credential race (credential mutations serialized; a failed password/role replacement revokes everything, including persisted keys, so nothing rebinds after restart), an atomic revocation check at the outbound frame fence, removal of the JS SDK's client-side vectorSearch rewrite, the implicit switch to the default KG before a drop and the local-file load (the server-file .load API is restored), an open-loop perf-gate writer, the GenBI converged-mutation count (missing truth is not convergence), the WAL rollback-failure contract text with its test, CI gating of the perf-gate tool checks and the Verified Completions gate to release checkpoints (full-suite only, off the fast PR gate), and make pre-pr, which requires the merge base with origin/main (or an explicit PRE_PR_BASE), routes changed paths to their own fast checks in parallel (reusing the unit-test target) and then runs the perf gate before every push to a PR. When rebasing onto main, keep #204's credential design (expiry, rotation grace, principal module) and re-apply these fixes on top of it. The post-commit flush durability defects in the WAL commit path are fixed separately against main's code, not here.

What Changed

  • Credential revocation (server): credential mutations are now serialized. Password and role changes replace the user row atomically, and if the outcome of that write is unknown, the user's sessions and persisted API keys are revoked so nothing reconnects after a restart. Dropping a user now also removes their API keys, key times and KG grants. Before a frame goes out on the WebSocket (ws/outbound.rs), the server checks that the credential is still live using a new Principal::admit. That check is atomic with revocation and with expiry being brought forward. Admin bootstrap now writes a bootstrap_keys record, so it never issues a second API key, and that record is backfilled for existing data directories. Tests cover all of this in credential_mutation_tests.rs and tests/credential_revocation_tests.rs. The WAL rollback-failure contract is now documented, with a test.
  • JS SDK, perf gate and GenBI: vectorSearch sends top_k / within_radius queries to the server again instead of sorting and slicing results in the client. dropKnowledgeGraph no longer switches to default first. load(path, mode?) is back to the server-file .load command. The perf-gate writer is now open-loop: it sends on a fixed schedule and reads replies concurrently. GenBI counts a mutation as converged only when every agent converged, so missing truth no longer counts as convergence.
  • CI and local gating: the perf-gate tool checks and the Verified Completions gate moved out of the fast PR CI and into full-suite.yml, which runs at release checkpoints. The standalone verified-completions.yml workflow is deleted. A new make pre-pr target needs a merge base with origin/main (or an explicit PRE_PR_BASE). It sends each changed path to its own fast checks in parallel (fmt, lint, unit-test, affected snapshots, Python/JS tests, perf-gate checks), then runs the perf gate. A new scripts/test_pre_pr.py tests that routing. CONTRIBUTING, TESTING.md, the PR template and the SDK, auth and persistence guides are updated to match.

Risk Assessment

✅ Low: The latest fix round only moved a doc comment back to fn now_ms, gave BOOTSTRAP_KEY_LABEL an accurate doc, and qualified the docs sentence. That sentence now matches replace_user_row's CommitError::Unknown handling and CommitError::Unknown's documented semantics, and it introduces no behavioral change.

Testing

I drove the change against real server processes in isolated /tmp data directories, which I deleted afterwards. - Credentials over WebSocket (all checks passed): - A password change ends only the password session, with a credential_revoked notice and close. - A role change takes effect on the next request without closing the session. - Revoking your own key withholds the reply. - A dropped and recreated user does not get its old keys or grants back. - A revoked bootstrap key stays rejected after restart, even with INPUTLAYER_BOOTSTRAP_API_KEY still set. This includes an upgrade from a data directory created by the base (main) server. - JS SDK integration suite on a live server: 46 of 47 pass, including the typed rejections for vectorSearch, .load and dropping the current graph. The one failure (unsupported vs not_found) reproduces identically on the base server, so it predates this change. - make pre-pr: the routing tests pass, and the real Makefile refuses a missing, invalid or self base before running any check. - Targeted Rust tests: the credential mutation, outbound fence, WAL rollback, credential-revocation and perf-gate/GenBI tests all pass. These are in-process tests, so the scenarios they cover are marked untested live. - Perf gate: a quick delta_single/interference run completed with its correctness checks. The full release make perf-gate could not run because the host disk was nearly full (as little as 127 MB free), filled by other processes. - Not driven live: the failed-replacement fault injection, the outbound-fence revocation race, the GenBI benchmark (needs an external genbi-trust checkout), the WAL crash-after-failed-cut-back case, and the GitHub Actions gating (needs GitHub's runners). For the workflows I parsed the YAML instead.

  • Live validation: ✅ go - 10 of 15 scenarios driven live against the product
Scenario Result Live Evidence
Admin changes a user's password: that user's password session gets credential_revoked and closes, their API-key session keeps working, old password rejected, new one accepted ✅ pass live live-sessions.log
Admin changes a user's role: the live session stays open and the next write is denied ✅ pass live live-sessions.log
A user revokes the API key their session uses: the reply is withheld, a credential_revoked notice arrives and the session closes; the key is rejected afterwards ✅ pass live live-sessions.log
Dropped then recreated user: the old key does not authenticate, the user has no keys and no old grant, and the old key is still rejected after restart ✅ pass live live-sessions.log; live-bootstrap-restart.log
Adversarial: revoke the bootstrap key and restart with INPUTLAYER_BOOTSTRAP_API_KEY still set; the key never authenticates again and the admin password still works ✅ pass live live-bootstrap-restart.log
Adversarial upgrade: data dir created by the base (main) server with admin and bootstrap key; the candidate boots on it, the key is revoked and the server restarted; the revoked key never authenticate… ✅ pass live live-upgrade-backfill.log
A failed password/role replacement changes nothing (row, keys and sessions intact); orphaned keys cannot rebind via recreation or bootstrap ⏸️ untested no The earlier payload did not establish a live result. Making the storage write fail in the middle of a replacement needs fault injection that only the in-process tests provide (failed_user_replacement_…
Revocation at the outbound frame fence: nothing committed after revocation reaches the revoked session ⏸️ untested no The earlier payload did not establish a live result. The race needs revocation timed against an in-flight frame enqueue, which only the in-process test hooks can control (the credential_revocation_tes…
JS SDK on a live server: vectorSearch sends a server query (typed QueryError, no client-side rewrite), load sends server .load (typed unsupported), dropping the current KG rejects without switching ✅ pass live js-sdk-integration-live.log
make pre-pr refuses a missing, invalid or self base before running any check, naming origin/main and PRE_PR_BASE ✅ pass live pre-pr-base-guard.txt; pre-pr-routing-tests.log
make pre-pr routes changed paths (including ws-protocol) to their own checks (unit-test reused), runs them in parallel, and runs the perf gate last only when they pass ✅ pass live pre-pr-routing-tests.log (real Makefile in disposable git repos with stubbed cargo/npm/uv)
Perf-gate delta fixtures with the open-loop writer run end to end against a real server, with every delta checked ✅ pass live perf-gate-quick-run.log; perf-gate-quick-run.json; open-loop schedule unit tests in targeted-rust-tests.log
GenBI: a mutation with missing truth is not counted as converged ⏸️ untested no The earlier payload did not establish a live result. The live benchmark (make bench-genbi) needs an external genbi-trust checkout via GENBI_TRUST_DIR, which is not set and not in the repo. Only the un…
WAL rollback-failure contract: a crash before a failed cut-back is repaired recovers the failed record ⏸️ untested no The earlier payload did not establish a live result. This needs a failed WAL truncation followed by a crash, which only the storage-level test can inject (crash_before_a_failed_cut_back_is_repaired_re…
CI: perf-gate tool checks and the Verified Completions gate run only in the full-suite (release checkpoint) workflow, not the fast PR gate ⏸️ untested no Running the workflows needs GitHub Actions runners, which this sandbox can't reach. I only parsed the workflow YAML (ci-gating-parse.txt: perf-gate-check and vc-gate appear only in full-suite.yml jobs…
Evidence: Live WS credential session scenarios (candidate server)
\### boot 1 (candidate server, fresh data dir)
2026-10-03T23:02:23.163452Z  INFO inputlayer::protocol::handler: Auth bootstrap: admin user created
2026-10-03T23:02:23.174350Z  INFO inputlayer::protocol::handler: Auth bootstrap: API key 'bootstrap' created for admin
[admin] login -> {"type":"authenticated","session_id":"8ba01baf-f2e2-4b56-bd54-f5425e9b91db","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
[admin] (unsolicited) {"type":"kg_change","knowledge_graph":"shared","operation":"created","timestamp_ms":1791068544086,"seq":1}
[admin] .kg create shared -> {"type":"result","id":"85412288","columns":["message"],"rows":[["Knowledge graph 'shared' created."],["Switched to knowledge graph: shared"]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown","unknown"],"switched_kg":"shared","errors":[]}
[admin] .user create ops opspw1 admin -> {"type":"result","id":"99422438","columns":["message"],"rows":[["User 'ops' created with role 'admin'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[admin] .kg acl grant shared ops viewer -> {"type":"result","id":"34216206","columns":["message"],"rows":[["Granted 'viewer' access on 'shared' to 'ops'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[ops-pw] login -> {"type":"authenticated","session_id":"9842770f-0832-44f2-bc17-a64dfb8f117c","knowledge_graph":"shared","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
[ops-pw] .apikey create opskey -> {"type":"result","id":"10633953","columns":["label","api_key","expires_at"],"rows":[["opskey","d39d7c6fb7018f2b114f1c01bc7ae09d6c7a28ea881b924f37f3cc7ff1c0fb0e",null]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
ops key captured: true
[ops-key] login -> {"type":"authenticated","session_id":"1a4654a5-010c-4f9a-851f-23abd624fe79","knowledge_graph":"shared","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
PASS ops authenticates with its API key
[admin] .user password ops opspw2 -> {"type":"result","id":"06940583","columns":["message"],"rows":[["Password updated for 'ops'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[ops-pw] drained -> [{"type":"notice","code":"credential_revoked","message":"Credential revoked; reconnect with valid credentials"},{"closed":true}]
PASS password change ends the password session with a credential_revoked notice and closes it
[ops-key] .kg list -> {"type":"result","id":"05700874","columns":["message"],"rows":[["Knowledge Graphs:"],["  default"],["  shared *"]],"row_count":3,"total_count":3,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown","unknown","unknown"],"errors":[]}
PASS API-key session survives the password change
[ops-oldpw] login -> {"type":"auth_error","message":"Invalid credentials"}
PASS old password rejected
[ops-newpw] login -> {"type":"authenticated","session_id":"79691287-9777-4d02-9565-d440da29d12f","knowledge_graph":"shared","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
PASS new password accepted
[ops-key] +rolecheck[(1,)] -> {"type":"result","id":"55835849","columns":["message"],"rows":[["Inserted 1 fact(s) into 'rolecheck'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":6,"row_provenance":["unknown"],"errors":[]}
PASS write succeeds as admin before role change
[admin] (unsolicited) {"type":"persistent_update","knowledge_graph":"shared","relation":"rolecheck","operation":"insert","count":1,"timestamp_ms":1791068546115,"seq":2}
[admin] .user role ops viewer -> {"type":"result","id":"55813444","columns":["message"],"rows":[["Role updated to 'viewer' for 'ops'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[ops-key] (unsolicited) {"type":"persistent_update","knowledge_graph":"shared","relation":"rolecheck","operation":"insert","count":1,"timestamp_ms":1791068546115,"seq":2}
[ops-key] +rolecheck[(2,)] -> {"type":"error","id":"80043742","message":"Permission denied: you have viewer access to this knowledge graph"}
PASS after role change to viewer, the write is denied on the same live session
[ops-key] ?rolecheck(X) -> {"type":"result","id":"76826316","columns":["X"],"rows":[[1]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":2,"row_provenance":["unknown"],"timing_breakdown":{"total_us":2183,"parse_us":139,"sip_us":52,"magic_sets_us":25,"ir_build_us":57,"optimize_us":70,"shared_views_us":0},"errors":[]}
PASS role change keeps the session open
[admin] .user role ops admin -> {"type":"result","id":"41064421","columns":["message"],"rows":[["Role updated to 'admin' for 'ops'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[ops-key] (unsolicited) {"type":"notice","code":"credential_revoked","message":"Credential revoked; reconnect with valid credentials"}
[ops-key] .apikey revoke opskey -> {"closed":true}
[ops-key] drained -> [{"closed":true}]
PASS revoking its own key withholds the reply and ends the session with credential_revoked
[ops-revoked] login -> {"type":"auth_error","message":"Invalid API key"}
PASS revoked key rejected on a new connection
[ops-pw3] login -> {"type":"authenticated","session_id":"f937f75a-0b79-486b-855b-3643bdafe9ea","knowledge_graph":"shared","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
[ops-pw3] .apikey create opskey2 -> {"type":"result","id":"50606609","columns":["label","api_key","expires_at"],"rows":[["opskey2","75b59e64e7f9a3f57b280e0db1ca3d55fb337eeccf2383632abe4005dc8895ce",null]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[ops-key2] login -> {"type":"authenticated","session_id":"52e6f931-09fc-4d44-bd24-59170d146f6a","knowledge_graph":"shared","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
[admin] .kg create finance -> {"type":"result","id":"95776540","columns":["message"],"rows":[["Knowledge graph 'finance' created."],["Switched to knowledge graph: finance"]],"row_count":2,"total_count":2,"truncated":false,"execution_time_ms":3,"row_provenance":["unknown","unknown"],"switched_kg":"finance","errors":[]}
[admin] (unsolicited) {"type":"kg_change","knowledge_graph":"finance","operation":"created","timestamp_ms":1791068546543,"seq":3}
[admin] .kg acl grant finance ops owner -> {"type":"result","id":"71744808","columns":["message"],"rows":[["Granted 'owner' access on 'finance' to 'ops'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[admin] .user drop ops -> {"type":"result","id":"31186843","columns":["message"],"rows":[["User 'ops' dropped."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[ops-key2] drained -> [{"type":"kg_change","knowledge_graph":"finance","operation":"created","timestamp_ms":1791068546543,"seq":3},{"type":"notice","code":"credential_revoked","message":"Credential revoked; reconnect with valid credentials"},{"closed":true}]
PASS dropping the user ends its key session
[admin] .user create ops opspw3 admin -> {"type":"result","id":"31416662","columns":["message"],"rows":[["User 'ops' created with role 'admin'."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[ops-oldkey] login -> {"type":"auth_error","message":"Invalid API key"}
PASS key of the dropped user does not authenticate the recreated user
[ops-new] login -> {"type":"authenticated","session_id":"824493fc-4ee4-4861-abe8-16258ae55b53","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
[ops-new] .apikey list -> {"type":"result","id":"15009429","columns":["label","owner","created_at","expires_at","last_used_at","status"],"rows":[["bootstrap","admin",1791068543167,null,null,"active"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
PASS recreated user starts with no API keys
[admin] .kg acl list finance -> {"type":"result","id":"46507287","columns":["message"],"rows":[["No ACL entries for 'finance'. Admins have implicit owner access."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
PASS recreated user has no grant on finance
OLDKEY=75b59e64e7f9a3f57b280e0db1ca3d55fb337eeccf2383632abe4005dc8895ce
ALL CHECKS PASSED
Evidence: Bootstrap key revoke + restart (supplied key never rebinds)
\### revoke bootstrap key on boot 1
[admin-bootkey] login -> {"type":"authenticated","session_id":"d6987bb1-a933-451a-96ba-f3c5e31080b6","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
PASS bootstrap API key authenticates as admin
[admin-bootkey] .apikey list -> {"type":"result","id":"95212870","columns":["label","owner","created_at","expires_at","last_used_at","status"],"rows":[["bootstrap","admin",1791068543167,null,1791068554455,"active"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[admin-pw] login -> {"type":"authenticated","session_id":"605f37da-7cbd-4324-a0eb-ed2a62406794","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"975dd18c04589fe6"}
[admin-pw] .apikey revoke bootstrap -> {"type":"result","id":"31518711","columns":["message"],"rows":[["API key 'bootstrap' revoked."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[admin-bootkey] drained -> [{"type":"notice","code":"credential_revoked","message":"Credential revoked; reconnect with valid credentials"},{"closed":true}]
[admin-bootkey-again] login -> {"type":"auth_error","message":"Invalid API key"}
PASS revoked bootstrap key rejected
ALL CHECKS PASSED
\### boot 2 (same data dir, INPUTLAYER_BOOTSTRAP_API_KEY still set)
2026-10-03T23:02:36.932294Z  INFO inputlayer::protocol::handler: Auth bootstrap: admin user already exists
[admin-bootkey-after-restart] login -> {"type":"auth_error","message":"Invalid API key"}
PASS revoked bootstrap key never authenticates after restart
[admin-pw-after-restart] login -> {"type":"authenticated","session_id":"b6dc3b41-3f49-4c1b-845b-4520cbeba3b5","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"62b5038cf6f23f6d"}
PASS admin password still logs in
[admin-pw-after-restart] .apikey list -> {"type":"result","id":"03552486","columns":["label","owner","created_at","expires_at","last_used_at","status"],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":0,"errors":[]}
ALL CHECKS PASSED
[ops-oldkey-after-restart] login -> {"type":"auth_error","message":"Invalid API key"}
PASS old key of dropped user still rejected after restart
ALL CHECKS PASSED
Evidence: Upgrade from base-server data dir: backfill blocks re-issue of revoked bootstrap key
\### boot 1: BASE server (main 9ed61b49), fresh data dir -> pre-change data dir
2026-10-03T23:03:25.654131Z  INFO inputlayer::protocol::handler: Auth bootstrap: admin user created
2026-10-03T23:03:25.895741Z  INFO inputlayer::protocol::handler: Auth bootstrap: API key 'bootstrap' created for admin
[admin] login -> {"type":"authenticated","session_id":"2167c0d3-6990-4c7c-a9ab-abcfbbddc920","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"fcadada14d07513c"}
[admin] .apikey list -> {"type":"result","id":"80455790","columns":["label","owner","created_at","expires_at","last_used_at","status"],"rows":[["bootstrap","admin",1791068605654,null,null,"active"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
ALL CHECKS PASSED
\### boot 2: CANDIDATE server on the pre-change data dir (upgrade)
2026-10-03T23:03:28.965924Z  INFO inputlayer::protocol::handler: Auth bootstrap: admin user already exists
[admin-bootkey] login -> {"type":"authenticated","session_id":"0f4fafec-e0bf-4468-bac5-14db728172df","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"8d8a661bd352c99a"}
PASS bootstrap API key authenticates as admin
[admin-bootkey] .apikey list -> {"type":"result","id":"33750793","columns":["label","owner","created_at","expires_at","last_used_at","status"],"rows":[["bootstrap","admin",1791068605654,null,1791068610357,"active"]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[admin-pw] login -> {"type":"authenticated","session_id":"fbb2d1f2-9890-410b-b576-5cb42ab45445","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"8d8a661bd352c99a"}
[admin-pw] .apikey revoke bootstrap -> {"type":"result","id":"00703268","columns":["message"],"rows":[["API key 'bootstrap' revoked."]],"row_count":1,"total_count":1,"truncated":false,"execution_time_ms":0,"row_provenance":["unknown"],"errors":[]}
[admin-bootkey] drained -> [{"type":"notice","code":"credential_revoked","message":"Credential revoked; reconnect with valid credentials"},{"closed":true}]
[admin-bootkey-again] login -> {"type":"auth_error","message":"Invalid API key"}
PASS revoked bootstrap key rejected
ALL CHECKS PASSED
\### boot 3: CANDIDATE server restart, INPUTLAYER_BOOTSTRAP_API_KEY still set
2026-10-03T23:03:33.379822Z  INFO inputlayer::protocol::handler: Auth bootstrap: admin user already exists
[admin-bootkey-after-restart] login -> {"type":"auth_error","message":"Invalid API key"}
PASS revoked bootstrap key never authenticates after restart
[admin-pw-after-restart] login -> {"type":"authenticated","session_id":"5674da1b-22e3-45a3-bedc-101c196fbdbc","knowledge_graph":"default","version":"0.1.0","role":"admin","protocol_version":2,"stream_epoch":"419ec51ac979453c"}
PASS admin password still logs in
[admin-pw-after-restart] .apikey list -> {"type":"result","id":"03729072","columns":["label","owner","created_at","expires_at","last_used_at","status"],"rows":[],"row_count":0,"total_count":0,"truncated":false,"execution_time_ms":0,"errors":[]}
ALL CHECKS PASSED
Evidence: JS SDK integration suite against live candidate server

 RUN  v3.2.7 ~/.no-mistakes/worktrees/9eea230c3aae/01M41XHZ3S11JK8J4VFFXZSJBA/packages/inputlayer-js

 ✓ tests/integration.test.ts > Integration: Connection > connects and authenticates 1ms
 ✓ tests/integration.test.ts > Integration: Connection > lists knowledge graphs 2ms
 ✓ tests/integration.test.ts > Integration: Schema > compileSchema produces valid IQL 0ms
 ✓ tests/integration.test.ts > Integration: Schema > defines and uses relations via SDK 42ms
 ✓ tests/integration.test.ts > Integration: Schema > define is idempotent 2ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > compileInsert produces valid IQL 1ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > inserts and queries batch facts 20ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > inserts a single fact 7ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > queries with filter via raw IQL 15ms
 ✓ tests/integration.test.ts > Integration: Insert & Query > queries with ordering and limit 5ms
 ✓ tests/integration.test.ts > Integration: Joins > joins two relations via raw IQL 9ms
 ✓ tests/integration.test.ts > Integration: Aggregations > count aggregation via rule 12ms
 ✓ tests/integration.test.ts > Integration: Aggregations > sum aggregation via rule 11ms
 ✓ tests/integration.test.ts > Integration: Aggregations > group-by count via rule 13ms
 ✓ tests/integration.test.ts > Integration: Aggregations > min/max aggregation via rule 25ms
 ✓ tests/integration.test.ts > Integration: Rules > compileRule produces valid IQL 1ms
 ✓ tests/integration.test.ts > Integration: Rules > defines and queries a recursive rule 40ms
 ✓ tests/integration.test.ts > Integration: Rules > lists rules 1ms
 ✓ tests/integration.test.ts > Integration: Rules > drops a rule 17ms
 ✓ tests/integration.test.ts > Integration: Delete > compileDelete produces valid IQL 0ms
 ✓ tests/integration.test.ts > Integration: Delete > deletes a specific fact via SDK 21ms
 ✓ tests/integration.test.ts > Integration: Delete > deletes by condition via SDK 22ms
 ✓ tests/integration.test.ts > Integration: Sessions > inserts ephemeral session facts 25ms
 ✓ tests/integration.test.ts > Integration: Sessions > clears session data 3ms
 ✓ tests/integration.test.ts > Integration: Vector Search > top-k query rejection is typed 2ms
 ✓ tests/integration.test.ts > Integration: Vector Search > radius query rejection is typed 1ms
 ✓ tests/integration.test.ts > Integration: Indexes > creates and drops an HNSW index 6ms
 ✓ tests/integration.test.ts > Integration: ResultSet > result has correct row count 3ms
 ✓ tests/integration.test.ts > Integration: ResultSet > first() returns first row 3ms
 ✓ tests/integration.test.ts > Integration: ResultSet > toDicts() returns array of objects 2ms
 ✓ tests/integration.test.ts > Integration: ResultSet > toTuples() returns array of arrays 2ms
 ✓ tests/integration.test.ts > Integration: ResultSet > is iterable 3ms
 ✓ tests/integration.test.ts > Integration: ResultSet > has execution metadata 2ms
 ✓ tests/integration.test.ts > Integration: Raw IQL > executes raw IQL statements 24ms
 ✓ tests/integration.test.ts > Integration: Raw IQL > server status 1ms
 ✓ tests/integration.test.ts > Integration: Multi-KG > creates and lists a knowledge graph 21ms
 ✓ tests/integration.test.ts > Integration: Debug > debugs a query plan via raw IQL 2ms
 ✓ tests/integration.test.ts > Integration: Drop Relation > drops a relation 26ms
 ✓ tests/integration.test.ts > Integration: ACL > lists ACL entries 1ms
 ✓ tests/integration.test.ts > Integration: Server Operations > gets server status 2ms
 ✓ tests/integration.test.ts > Integration: Server Operations > triggers compaction without error 58ms
 ✓ tests/integration.test.ts > Integration: Engine failures > a failed insert rejects 1ms
 ✓ tests/integration.test.ts > Integration: Engine failures > insert counts the facts the engine stored 11ms
 × tests/integration.test.ts > Integration: Engine failures > a partial failure names each failed statement 10ms
   → expected [ [ 1, 'unsupported' ] ] to deeply equal [ [ 1, 'not_found' ] ]
 ✓ tests/integration.test.ts > Integration: Engine failures > a partial failure survives a chunked result 941ms
 ✓ tests/integration.test.ts > Integration: Engine failures > load is refused over the WebSocket with a typed error 2ms
 ✓ tests/integration.test.ts > Integration: Engine failures > dropping the current knowledge graph rejects without switching it 9ms

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/integration.test.ts > Integration: Engine failures > a partial failure names each failed statement
AssertionError: expected [ [ 1, 'unsupported' ] ] to deeply equal [ [ 1, 'not_found' ] ]

- Expected
+ Received

  [
    [
      1,
-     "not_found",
+     "unsupported",
    ],
  ]

 ❯ tests/integration.test.ts:846:54
    844|       .catch((e: unknown) => e)) as StatementFailedError;
    845|     expect(err).toBeInstanceOf(StatementFailedError);
    846|     expect(err.errors.map((e) => [e.index, e.code])).toEqual([[1, 'not…
       |                                                      ^
    847|     // The engine ran the other statements.
    848|     expect((await kg.execute('?edge(200, Y)')).length).toBe(1);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 46 passed (47)
   Start at  23:03:44
   Duration  10.06s (transform 163ms, setup 0ms, collect 206ms, tests 9.59s, environment 0ms, prepare 73ms)
Evidence: Same partial-failure JS test failing on base (main) server

 RUN  v3.2.7 ~/.no-mistakes/worktrees/9eea230c3aae/01M41XHZ3S11JK8J4VFFXZSJBA/packages/inputlayer-js

 ❯ tests/integration.test.ts (47 tests | 1 failed | 46 skipped) 916ms
   ↓ Integration: Connection > connects and authenticates
   ↓ Integration: Connection > lists knowledge graphs
   ↓ Integration: Schema > compileSchema produces valid IQL
   ↓ Integration: Schema > defines and uses relations via SDK
   ↓ Integration: Schema > define is idempotent
   ↓ Integration: Insert & Query > compileInsert produces valid IQL
   ↓ Integration: Insert & Query > inserts and queries batch facts
   ↓ Integration: Insert & Query > inserts a single fact
   ↓ Integration: Insert & Query > queries with filter via raw IQL
   ↓ Integration: Insert & Query > queries with ordering and limit
   ↓ Integration: Joins > joins two relations via raw IQL
   ↓ Integration: Aggregations > count aggregation via rule
   ↓ Integration: Aggregations > sum aggregation via rule
   ↓ Integration: Aggregations > group-by count via rule
   ↓ Integration: Aggregations > min/max aggregation via rule
   ↓ Integration: Rules > compileRule produces valid IQL
   ↓ Integration: Rules > defines and queries a recursive rule
   ↓ Integration: Rules > lists rules
   ↓ Integration: Rules > drops a rule
   ↓ Integration: Delete > compileDelete produces valid IQL
   ↓ Integration: Delete > deletes a specific fact via SDK
   ↓ Integration: Delete > deletes by condition via SDK
   ↓ Integration: Sessions > inserts ephemeral session facts
   ↓ Integration: Sessions > clears session data
   ↓ Integration: Vector Search > top-k query rejection is typed
   ↓ Integration: Vector Search > radius query rejection is typed
   ↓ Integration: Indexes > creates and drops an HNSW index
   ↓ Integration: ResultSet > result has correct row count
   ↓ Integration: ResultSet > first() returns first row
   ↓ Integration: ResultSet > toDicts() returns array of objects
   ↓ Integration: ResultSet > toTuples() returns array of arrays
   ↓ Integration: ResultSet > is iterable
   ↓ Integration: ResultSet > has execution metadata
   ↓ Integration: Raw IQL > executes raw IQL statements
   ↓ Integration: Raw IQL > server status
   ↓ Integration: Multi-KG > creates and lists a knowledge graph
   ↓ Integration: Debug > debugs a query plan via raw IQL
   ↓ Integration: Drop Relation > drops a relation
   ↓ Integration: ACL > lists ACL entries
   ↓ Integration: Server Operations > gets server status
   ↓ Integration: Server Operations > triggers compaction without error
   ↓ Integration: Engine failures > a failed insert rejects
   ↓ Integration: Engine failures > insert counts the facts the engine stored
   × Integration: Engine failures > a partial failure names each failed statement 10ms
     → expected [ [ 1, 'unsupported' ] ] to deeply equal [ [ 1, 'not_found' ] ]
   ↓ Integration: Engine failures > a partial failure survives a chunked result
   ↓ Integration: Engine failures > load is refused over the WebSocket with a typed error
   ↓ Integration: Engine failures > dropping the current knowledge graph rejects without switching it

⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯

 FAIL  tests/integration.test.ts > Integration: Engine failures > a partial failure names each failed statement
AssertionError: expected [ [ 1, 'unsupported' ] ] to deeply equal [ [ 1, 'not_found' ] ]

- Expected
+ Received

  [
    [
      1,
-     "not_found",
+     "unsupported",
    ],
  ]

 ❯ tests/integration.test.ts:846:54
    844|       .catch((e: unknown) => e)) as StatementFailedError;
    845|     expect(err).toBeInstanceOf(StatementFailedError);
    846|     expect(err.errors.map((e) => [e.index, e.code])).toEqual([[1, 'not…
       |                                                      ^
    847|     // The engine ran the other statements.
    848|     expect((await kg.execute('?edge(200, Y)')).length).toBe(1);

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯[1/1]⎯


 Test Files  1 failed (1)
      Tests  1 failed | 46 skipped (47)
   Start at  23:04:10
   Duration  1.39s (transform 168ms, setup 0ms, collect 215ms, tests 916ms, environment 0ms, prepare 70ms)
Evidence: make pre-pr base guard transcript
$ make pre-pr PRE_PR_BASE=HEAD
pre-pr requires a merge base with origin/main or an explicit PRE_PR_BASE resolving to a commit other than HEAD
make: *** [Makefile:531: pre-pr] Error 1
exit=2
$ make pre-pr PRE_PR_BASE=no-such-ref
pre-pr requires a merge base with origin/main or an explicit PRE_PR_BASE resolving to a commit other than HEAD
make: *** [Makefile:531: pre-pr] Error 1
exit=2
$ make pre-pr PRE_PR_BASE=
pre-pr requires a merge base with origin/main or an explicit PRE_PR_BASE resolving to a commit other than HEAD
make: *** [Makefile:531: pre-pr] Error 1
exit=2
Evidence: pre-pr routing tests
test_component_checks_overlap (scripts.test_pre_pr.PrePrRoutingTests.test_component_checks_overlap) ... ok
test_failure_prevents_performance_run (scripts.test_pre_pr.PrePrRoutingTests.test_failure_prevents_performance_run) ... ok
test_merge_base_selects_committed_changes (scripts.test_pre_pr.PrePrRoutingTests.test_merge_base_selects_committed_changes) ... ok
test_missing_invalid_and_self_bases_stop_before_checks (scripts.test_pre_pr.PrePrRoutingTests.test_missing_invalid_and_self_bases_stop_before_checks) ... ok
test_routes_each_component_and_orders_performance_last (scripts.test_pre_pr.PrePrRoutingTests.test_routes_each_component_and_orders_performance_last) ... ok

----------------------------------------------------------------------
Ran 5 tests in 3.567s

OK
Evidence: Targeted Rust test results
running 9 tests
.........
test result: ok. 9 passed; 0 failed; 0 ignored; 0 measured; 2925 filtered out; finished in 6.67s

running 14 tests
..............
test result: ok. 14 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 8.52s

error: package ID specification `perf-gate` did not match any packages
error: could not compile `syn` (lib) due to 1 previous error
error: could not compile `clap_builder` (lib) due to 1 previous error
error: failed to write to `~/.no-mistakes/worktrees/9eea230c3aae/01M41XHZ3S11JK8J4VFFXZSJBA/target/debug/deps/rmetajr1XGC/full.rmeta`: No space left on device (os error 28)

error: could not compile `serde_json` (lib) due to 1 previous error
error: failed to write ~/.no-mistakes/worktrees/9eea230c3aae/01M41XHZ3S11JK8J4VFFXZSJBA/target/debug/deps/rustcD7KYmk/lib.rmeta: No space left on device (os error 28)

error: could not compile `zerocopy` (lib) due to 1 previous error

running 50 tests
..................................................
test result: ok. 50 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
Evidence: Perf-gate quick delta/interference run
round 1/1 delta_single   baseline  ack_us p50 4.60ms p99 9.54ms | delta_us p50 22.64ms p99 28.39ms | last_agent_us p50 22.64ms p99 28.39ms | subscribe_us p50 20.06ms p99 20.06ms
round 1/1 delta_single   candidate ack_us p50 4.74ms p99 12.50ms | delta_us p50 23.35ms p99 31.38ms | last_agent_us p50 23.35ms p99 31.38ms | subscribe_us p50 20.12ms p99 20.12ms
round 1/1 interference   candidate ack_us p50 4.47ms p99 9.47ms | delta_us p50 22.75ms p99 31.63ms | long_request_us p50 2337.60ms p99 2337.60ms
round 1/1 interference   baseline  ack_us p50 4.53ms p99 6.60ms | delta_us p50 23.15ms p99 32.57ms | long_request_us p50 2318.86ms p99 2318.86ms
run file: ~/.no-mistakes/evidence/01M41XHZ3S11JK8J4VFFXZSJBA/perf-gate-quick-run.json
Evidence: Perf-gate run file
{"schema":"inputlayer-perf-gate/v1","created_unix":1791068751,"environment":{"hostname":"computeinstance-e00sx5ndp6kwb7pqfv","kernel":"6.11.0-1016-nvidia","cpu_model":"AMD EPYC-Genoa Processor","logical_cpus":32,"mem_total_kb":131897728,"cpu_governor":"unknown","loadavg_start":"8.98 14.06 12.48 2/1903 3554270","loadavg_end":"8.16 13.63 12.36 4/1914 3559330","data_fs":"ext4","data_root":"/tmp/il-pg/servers","server_cpus":null,"build":"debug smoke"},"workload":{"profile":"quick","rounds":1,"fixtures":["delta_single","interference"],"server_overrides":{"INPUTLAYER_HTTP__GUI__ENABLED":"false (the GUI is not under test)","INPUTLAYER_HTTP__RATE_LIMIT__PER_IP_MAX_RPS":"0 (every gate client shares one loopback IP)","INPUTLAYER_HTTP__RATE_LIMIT__WS_MAX_MESSAGES_PER_SEC":"0 (per-connection message cap would bound the measured throughput)"},"parameters":{"bound_query":{"clients":8,"edges":4000,"nodes":2000,"per_client":5,"serial":30,"warmup":5},"cheap_query":{"clients":8,"edges":4000,"nodes":2000,"per_client":30,"serial":80,"warmup":5},"delta_fanout":{"edges":10000,"interval_ms":80,"nodes":2500,"subscribers":16,"writes":20},"delta_first":{"cycles":10,"edges":10000,"nodes":2500,"settle_ms":100},"delta_single":{"edges":10000,"interval_ms":20,"nodes":2500,"subscribers":1,"writes":30},"insert":{"batch_size":1000,"batches":4,"per_writer":10,"single":40,"writers":4},"interference":{"delta":{"edges":10000,"interval_ms":30,"nodes":2500,"subscribers":1,"writes":24},"slow_consumer_requests":8},"name":"quick"}},"arms":[{"name":"baseline","label":"candidate-debug","binary":"~/.no-mistakes/worktrees/9eea230c3aae/01M41XHZ3S11JK8J4VFFXZSJBA/target/live/server-new","binary_sha256":"e4df5728b593938676cab508ba90aa33cdce18132209e667713e7194f0538845"},{"name":"candidate","label":"candidate-debug","binary":"~/.no-mistakes/worktrees/9eea230c3aae/01M41XHZ3S11JK8J4VFFXZSJBA/target/live/server-new","binary_sha256":"e4df5728b593938676cab508ba90aa33cdce18132209e667713e7194f0538845"}],"runs":[{"arm":"baseline","round":0,"fixture":"delta_single","series":{"ack_us":[4354,4019,4922,4743,4883,5031,4322,4516,4601,4723,5123,4521,4886,4918,4106,3976,4277,4428,4654,4854,7214,4333,4879,7625,4838,4388,4180,4038,4323,9541],"delta_us":[21161,20629,21765,21830,22182,22208,22317,21865,21996,22244,23846,22374,22639,22632,21951,22449,22903,24157,22779,23326,25841,24472,23570,26176,25067,23798,22719,23775,23192,28386],"last_agent_us":[21161,20629,21765,21830,22182,22208,22317,21865,21996,22244,23846,22374,22639,22632,21951,22449,22903,24157,22779,23326,25841,24472,23570,26176,25067,23798,22719,23775,23192,28386],"subscribe_us":[20064]},"rates":{},"gauges":{"server_peak_rss_kb":85216},"error":null},{"arm":"candidate","round":0,"fixture":"delta_single","series":{"ack_us":[4436,4906,5020,3896,4158,4171,4336,7597,5423,4673,4777,5500,5577,4740,5014,6815,4808,4169,5125,4216,4650,5068,4267,4540,12504,4511,4735,4434,4972,5503],"delta_us":[21506,22772,21958,21300,21083,21319,21614,25229,23014,22362,22634,23414,24086,22817,23592,25008,24888,23861,23708,22688,23085,23861,22805,23349,31375,30217,29276,28629,28827,29415],"last_agent_us":[21506,22772,21958,21300,21083,21319,21614,25229,23014,22362,22634,23414,24086,22817,23592,25008,24888,23861,23708,22688,23085,23861,22805,23349,31375,30217,29276,28629,28827,29415],"subscribe_us":[20117]},"rates":{},"gauges":{"server_peak_rss_kb":82456},"error":null},{"arm":"candidate","round":0,"fixture":"interference","series":{"ack_us":[4800,4676,4466,4760,4224,4241,4491,4608,4020,3768,4599,4667,9468,4240,4516,4234,4953,4413,4217,4650,4607,4268,3931,3810],"delta_us":[22619,21857,21891,22752,21651,22046,22055,23374,22442,21604,22747,31627,28475,23407,30967,31280,23511,22692,22864,23249,24582,27207,22968,22544],"long_request_us":[2337597]},"rates":{},"gauges":{"server_peak_rss_kb":272444},"error":null},{"arm":"baseline","round":0,"fixture":"interference","series":{"ack_us":[4532,4489,5007,4153,4010,4692,5017,5253,3977,4702,4887,4230,4436,3986,4668,5093,4796,4571,4500,5229,4467,4486,4021,6602],"delta_us":[21581,21813,22315,22496,23989,22931,23246,23365,21849,22253,24357,22855,31214,22390,24317,24071,32463,32571,23165,23808,22973,23147,22512,25358],"long_request_us":[2318855]},"rates":{},"gauges":{"server_peak_rss_kb":276560},"error":null}]}
Evidence: CI workflow gating (parsed YAML)
.github/workflows/ci.yml triggers= ['pull_request']
.github/workflows/full-suite.yml triggers= ['push', 'pull_request', 'workflow_dispatch', 'workflow_call']
   job perf-gate-tool runs perf-gate-check if= None
   job verified-completions runs vc-gate if= None
verified-completions.yml exists: False
{'push': {'branches': ['release/**']}, 'pull_request': {'branches': ['main'], 'types': ['opened', 'synchronize', 'reopened', 'labeled']}, 'workflow_dispatch': None, 'workflow_call': None}
{'group': "full-suite-${{ github.event.pull_request.number || github.ref }}-${{ (github.event.action == 'labeled' && github.event.label.name != 'release') && github.run_id || 'run' }}", 'cancel-in-progress': "${{ !startsWith(github.ref, 'refs/tags/') }}"}
  • Evidence: Live WS driver script (local file: ~/.no-mistakes/evidence/01M41XHZ3S11JK8J4VFFXZSJBA/drive.mjs)
  • Outcome: ⚠️ 2 infos across 1 run (11m9s)

Pipeline

Updates from git push no-mistakes

... (6 earlier update rounds omitted to keep the PR body within GitHub's 65536-char limit; full history is in the run log.)

🔧 **Review** - 3 issues found → auto-fixed (3) ✅

🔧 Fix applied.
7 issues (4 warnings, 3 infos) still open:

  • ⚠️ src/protocol/handler.rs:1344 - The Argon2 password hash is now computed while the global storage write lock is held. handle_user_create acquires self.storage.write() at :1339 and only then evaluates auth::hash_password(password)? at :1344. handle_user_password does the same: it takes the write lock at :1485 and hashes at :1509. The base code held a read lock here, so other readers kept running. Now every query and write on every knowledge graph stalls for the full Argon2 run, which is tens of milliseconds in release builds and much longer in debug builds, on each .user create or .user password. The hash depends only on the plaintext, not on any locked state. Fix: compute it before taking the lock (or before create_user) so the serialized section covers only validation, persistence and publication.
  • ⚠️ src/protocol/handler.rs:1017 - Bootstrap now issues the API key only when _internal did not exist before this boot. create_api_key = !list_knowledge_graphs().contains(_internal) also suppresses the key in a case that has nothing to do with recovering a revoked admin. Example: on first boot, ensure_knowledge_graph creates _internal, then saving credentials.toml fails with a generated password (or hash_password or create_user fails), and the function returns before any user exists. On the next boot _internal exists with no users, so a fresh random key is generated, saved to credentials.toml and printed as 'Admin API key: ****xxxx', but it is never stored. The operator is handed a key that cannot authenticate. The same happens when an operator deletes credentials.toml during recovery: a new key is generated and printed but not issued. Only a supplied or persisted plaintext can match a revoked key; a freshly generated one cannot. A narrower rule would suppress issuance only for supplied or persisted keys when recovering an existing graph and still issue newly generated keys. That changes the documented rule in authentication.mdx ('Automatic key issuance happens only when the internal auth graph is first created'). Alternatively, do not generate, save or print a key that will not be issued. Either remedy changes documented bootstrap behaviour, so it needs your decision.
  • ⚠️ src/protocol/handler.rs:1393 - create_user deletes a recreated user's orphaned api_keys rows but not the old user's kg_acls grants. A failed password or role replacement deletes the users row and stops (the failure paths are at :1522 and :1579). The ACL cleanup exists only in handle_user_drop (:1468), so the old user's kg_acls rows stay. Example: bob has owner on KG finance; .user role bob viewer fails after the delete; an admin then runs .user create bob pw viewer. The new bob silently inherits owner on finance. Keys are cleaned at this boundary but grants are not. The code and docs do not say whether a recreated username should inherit orphaned per-KG grants. Should create_user also delete orphaned kg_acls rows in the same transaction, or is inheritance intended?
  • ⚠️ src/protocol/handler.rs:1047 - This fix round introduced the problem. The F2 change now decides bootstrap key issuance only from the new bootstrap_keys relation, and nothing backfills that relation for data directories created before this change. Those directories include every deployment of main/auth: API key expiry, rotation grace and batched last-used (#49) #204, whose _internal already holds an admin user and a bootstrap API key but no bootstrap_keys rows. Failing sequence: (1) such a deployment upgrades; (2) the operator revokes the bootstrap key, as the docs recommend; (3) a later .user password admin … or .user role admin … deletes the admin row and then fails to insert the replacement, so the admin row and its keys are deleted; (4) on restart, seed_admin_credentials finds no users and no bootstrap_keys rows, so issue_api_key is true. resolve then returns the old plaintext from credentials.toml, or from INPUTLAYER_BOOTSTRAP_API_KEY if it is still set. The server stores the same hash again, so the revoked key authenticates as admin. The previous rule (issue only when _internal is first created) blocked this, so the round moved the R1/rebase-2 invariant ('nothing rebinds after restart, including via the admin bootstrap') rather than closing it. A smallest fix: on boot, when the admin user exists and bootstrap_keys is empty, record the hash of the supplied or persisted bootstrap key. Alternatively, treat a pre-existing _internal with no bootstrap_keys rows as already issued. Both add a one-time migration write, so the remedy needs authorization. The new test partial_first_boot_issues_a_working_key_on_the_next_boot and the admin case of orphaned_keys_cannot_rebind_after_recreation_or_bootstrap start from a fresh data directory, so neither exercises this path.
  • ℹ️ src/protocol/handler.rs:1172 - This is a gap left by the F2 fix round. The new key is saved to credentials.toml and printed (masked) at :1093-1110, before the bootstrap_keys record (:1172) and store_api_key (:1188) succeed. If either insert fails, the operator still gets a printed, saved key that cannot authenticate. The operator's F2 decision says 'never generate, print or save a key that was not stored'. The next boot will not repair this because the admin user already exists. Fix: print the key only after the key row is stored, or remove the saved key from credentials.toml when an insert fails. Reaching this needs a storage failure right after the admin insert succeeds, so it is rare.
  • ℹ️ src/protocol/handler.rs:245 - This fix round inserted BOOTSTRAP_KEY_LABEL, record_bootstrap_key and store_bootstrap_key between the doc comment /// Current epoch milliseconds. and fn now_ms. As a result, BOOTSTRAP_KEY_LABEL now carries a two-line doc that begins 'Current epoch milliseconds.', and now_ms has no doc comment. Fix: move that doc line back above fn now_ms.
  • ℹ️ docs/content/docs/guides/authentication.mdx:229 - The new docs say a failed password or role replacement means 'nothing changes, and the user's row, API keys and live sessions stay as they were'. That does not hold for CommitError::Unknown. In that case replace_user_row (src/protocol/handler.rs:1614-1617) calls credentials.remove_user, which revokes the user's live sessions and keys in memory until restart, because the write is durable but was not applied. Fix: qualify the sentence, for example 'unless the write's outcome is unknown, in which case the user's live credentials are revoked until restart'.

🔧 Fix applied.
✅ Re-checked - no issues remain.

⚠️ **Test** - 2 infos
  • ℹ️ packages/inputlayer-js/tests/integration.test.ts:846 - One JS SDK integration test fails against a live server: 'a partial failure names each failed statement' gets code 'unsupported' where it expects 'not_found' for .rel drop no_such_rel inside a 3-statement program. It fails identically on the base (main 9ed61b4) server, and this branch does not modify the test, so the failure predates this change (evidence: js-partial-failure-on-base-server.log).
  • ℹ️ The full release make perf-gate (approved baseline vs candidate, judged) could not run because the host disk was nearly full (as little as 127 MB free), filled by other processes. Only a quick-profile debug run of the delta fixtures ran. To produce the judged report pre-pr requires, free disk space and run make perf-gate.
  • Live validation: ✅ go - 10 of 15 scenarios driven live against the product
Scenario Result Live Evidence
Admin changes a user's password: that user's password session gets credential_revoked and closes, their API-key session keeps working, old password rejected, new one accepted ✅ pass live live-sessions.log
Admin changes a user's role: the live session stays open and the next write is denied ✅ pass live live-sessions.log
A user revokes the API key their session uses: the reply is withheld, a credential_revoked notice arrives and the session closes; the key is rejected afterwards ✅ pass live live-sessions.log
Dropped then recreated user: the old key does not authenticate, the user has no keys and no old grant, and the old key is still rejected after restart ✅ pass live live-sessions.log; live-bootstrap-restart.log
Adversarial: revoke the bootstrap key and restart with INPUTLAYER_BOOTSTRAP_API_KEY still set; the key never authenticates again and the admin password still works ✅ pass live live-bootstrap-restart.log
Adversarial upgrade: data dir created by the base (main) server with admin and bootstrap key; the candidate boots on it, the key is revoked and the server restarted; the revoked key never authenticate… ✅ pass live live-upgrade-backfill.log
A failed password/role replacement changes nothing (row, keys and sessions intact); orphaned keys cannot rebind via recreation or bootstrap ⏸️ untested no The earlier payload did not establish a live result. Making the storage write fail in the middle of a replacement needs fault injection that only the in-process tests provide (failed_user_replacement_…
Revocation at the outbound frame fence: nothing committed after revocation reaches the revoked session ⏸️ untested no The earlier payload did not establish a live result. The race needs revocation timed against an in-flight frame enqueue, which only the in-process test hooks can control (the credential_revocation_tes…
JS SDK on a live server: vectorSearch sends a server query (typed QueryError, no client-side rewrite), load sends server .load (typed unsupported), dropping the current KG rejects without switching ✅ pass live js-sdk-integration-live.log
make pre-pr refuses a missing, invalid or self base before running any check, naming origin/main and PRE_PR_BASE ✅ pass live pre-pr-base-guard.txt; pre-pr-routing-tests.log
make pre-pr routes changed paths (including ws-protocol) to their own checks (unit-test reused), runs them in parallel, and runs the perf gate last only when they pass ✅ pass live pre-pr-routing-tests.log (real Makefile in disposable git repos with stubbed cargo/npm/uv)
Perf-gate delta fixtures with the open-loop writer run end to end against a real server, with every delta checked ✅ pass live perf-gate-quick-run.log; perf-gate-quick-run.json; open-loop schedule unit tests in targeted-rust-tests.log
GenBI: a mutation with missing truth is not counted as converged ⏸️ untested no The earlier payload did not establish a live result. The live benchmark (make bench-genbi) needs an external genbi-trust checkout via GENBI_TRUST_DIR, which is not set and not in the repo. Only the un…
WAL rollback-failure contract: a crash before a failed cut-back is repaired recovers the failed record ⏸️ untested no The earlier payload did not establish a live result. This needs a failed WAL truncation followed by a crash, which only the storage-level test can inject (crash_before_a_failed_cut_back_is_repaired_re…
CI: perf-gate tool checks and the Verified Completions gate run only in the full-suite (release checkpoint) workflow, not the fast PR gate ⏸️ untested no Running the workflows needs GitHub Actions runners, which this sandbox can't reach. I only parsed the workflow YAML (ci-gating-parse.txt: perf-gate-check and vc-gate appear only in full-suite.yml jobs…
  • python3 -m unittest scripts/test_pre_pr.py -v (real Makefile in disposable git repos: routing, merge-base use, missing/invalid/self base refusal, parallel overlap, perf gate last)
  • make pre-pr PRE_PR_BASE=HEAD|no-such-ref|'' in the real worktree (fails before any check runs)
  • cargo test --lib -- credential_mutation_tests revocation_waits_for_final_frame_enqueue crash_before_a_failed_cut_back_is_repaired
  • cargo test --test credential_revocation_tests (14 WS end-to-end revocation tests)
  • cargo test --manifest-path perf-gate/Cargo.toml --target-dir target (includes the GenBI convergence and open-loop delta tests)
  • Live: candidate inputlayer-server + node WS driver (drive.mjs sessions): password change, role change, own-key revocation, user drop/recreate, key and grant non-inheritance
  • Live: revoke the bootstrap key, restart with INPUTLAYER_BOOTSTRAP_API_KEY still set, check the revoked key and the dropped user's old key are rejected
  • Live upgrade: base 9ed61b49 server creates the data dir with admin and bootstrap key, candidate server boots on it, the key is revoked and the server restarted; the revoked key never authenticates
  • Live: npx vitest run tests/integration.test.ts against the candidate server (47 tests)
  • Live: the same failing JS test against the base server to classify the failure
  • Live: perf-gate run --profile quick --rounds 1 --fixtures delta_single,interference against the candidate debug server
  • Parsed .github/workflows with PyYAML to confirm perf-gate-check and vc-gate run only in full-suite.yml
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

@jsam jsam changed the title fix: close credential revocation races and correct SDK and checkpoint behavior fix: close credential revocation races and apply checkpoint-1 review fixes Oct 3, 2026
jsam added 19 commits October 3, 2026 22:18
…oints

The workflow ran on every matching PR update and again after merge. It
now runs on PRs carrying the release label, on release/** branches, on
v* tags and on demand; make vc-gate covers local runs.
A failed commit whose cut-back also fails is repaired only in memory,
so a crash before the next write or a clean shutdown recovers it. The
guides and the append contract claimed a failed write is never
recovered; correct them and pin the behaviour with a test.
… did

converged_mutations counted every sent mutation, including those whose
subscriptions diverged (as with --fault drop-retractions). Judge
convergence per mutation across all subscriptions and time it from the
converged arrivals only.
It runs formatting, clippy and the tests that apply (workspace tests plus
the snapshot categories affected since the branch left main) in parallel,
then the performance gate. CONTRIBUTING, TESTING.md and the PR template
require it. test-affected.sh now joins categories with basic-grep
alternation, so a change touching several categories selects them all
instead of none.
…suite

One release-checkpoint policy for every expensive check: both become
full-suite jobs behind its release gate, and the separate Verified
Completions workflow is removed. The fast PR gate stays format, clippy
and unit + integration tests.
The gate applies to each push, not only to opening the PR. pre-pr also
skips the workspace tests when no Rust input changed since the branch
left main, so docs-only pushes run only the checks that apply.
load(path, mode) sends .load to the engine again instead of reading a
client-local file; over the WebSocket the engine refuses it as
client-only, which now rejects with QueryError(unsupported) rather than
passing silently. The SDK guides also flag insert()'s newly-stored count
as a behaviour change.
- auth: serialize every credential mutation (validation, storage and
  registry publication) so a key revoked mid-create cannot be published,
  and fail closed when a password or role change deletes the old record
  but cannot write the new one.
- ws: make the outbound fence's final revocation check and the frame
  enqueue atomic with respect to revocation on every data-frame path.
- sdk-js: drop the client-side vectorSearch rewrite and the implicit
  switch to the default knowledge graph before a drop; the server's
  typed error surfaces instead.
- perf-gate: delta and interference writers are open loop, sending on
  schedule regardless of acknowledgements, with latency measured from
  the scheduled time.
- make pre-pr routes changed paths to their own fast checks (Python SDK,
  JS SDK, perf-gate tool, Rust tests and snapshots) in parallel before
  the performance gate.
- The mid-proof revocation test waits for the revocation to land before
  judging the reply.
They run in the full suite at release checkpoints; the fast gate stays
format, clippy and unit + integration tests.
Main's atomic programs report a rejected insert as 'Insert rejected for
<relation>' and no longer re-export the schema types through the handler
module.
@jsam jsam changed the title fix: close credential revocation races and apply checkpoint-1 review fixes fix(protocol): close credential revocation races and apply checkpoint-1 review fixes Oct 3, 2026
@jsam

jsam commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

Superseded by #225 (same fixes rebased onto main, merged with CI green).

@jsam jsam closed this Oct 4, 2026
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