Skip to content

fix(protocol): permission refusals carry the access_denied error code - #387

Merged
jsam merged 3 commits into
mainfrom
fm/il-364-access-denied
Oct 5, 2026
Merged

jsam merged 3 commits into
mainfrom
fm/il-364-access-denied

Conversation

@jsam

@jsam jsam commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Intent

Fix GitHub issue #364 in inputlayer/inputlayer (production readiness): "Permission refusals carry no structured error code (access_denied)".

Found by the scenario suite (S15, #336): when a scoped key is refused (role or per-relation grant), the engine returns only a message, no structured ErrorCode. Clients and the API spec (access_denied) need a stable code. Add an access_denied code to every permission refusal path (roles, grants, admin-only commands), keep messages, and cover it in the scenario.

Deliver it as a small PR through the merge train; done means merged with CI green.

What Changed

  • Added an access_denied error code to the wire protocol (ErrorCode::AccessDenied) and made every permission refusal in the handler return it with its message unchanged: global and per-knowledge-graph role checks, admin-only commands, write grants, API key scope, the internal system knowledge graph, and revoked or expired credentials. The authorization paths now return a ProgramError (message + code) instead of a bare String.
  • auth_error and subscription_error frames gained an optional code field, set to access_denied when a credential may not use the knowledge graph at connect time or a subscriber loses read access; other auth and subscription failures still send no code. access_denied and replica_unconfirmed were also added to the error-code label list in src/protocol/metrics.rs.
  • Mirrored the new code and fields in the JS and Python clients, the GUI WebSocket types, and the AsyncAPI spec, error reference and WebSocket guide. Scenario S15 now asserts access_denied directly (its KnownDefect marker for Permission refusals carry no structured error code (access_denied) #364 is removed), with new handler-level tests in tests/authz_multistatement.rs, WebSocket tests in tests/credential_revocation_tests.rs, and serialization tests in ws-protocol and the Python client.

🤖 Generated with Claude Code

Risk Assessment

✅ Low: The change is an additive error code threaded through the single shared authorization boundary (authorize_program / Principal::identity / finish_subscribe) with messages unchanged and SDK message-based handling unaffected; the only open question is whether auth_error and subscription_error refusals, which have no code field, belong in this issue's scope.

Testing

Built inputlayer-server from the target commit, started it on a throwaway data directory and port, and drove 35 checks over real /ws connections with a Node script covering every refusal path plus adversarial non-permission failures; all passed, and /metrics/prometheus counted the same 20 refused error frames under access_denied. Also ran the S15 tenancy scenario (spawns a real engine process), which passed. The Python SDK protocol tests passed in a throwaway venv (37 passed), but they are unit tests of the deserializer and not a live run, so the SDK scenario is marked untested. I did not rebuild the base commit to reproduce the pre-fix behaviour, and the JS SDK and GUI type changes were not exercised. No UI surface is changed, so there is no screenshot; the evidence is the wire transcript. The server was stopped, temp directories removed, and the worktree is clean.

  • Live validation: ✅ go - 10 of 11 scenarios driven live against the product
Scenario Result Live Evidence
A viewer (user login and viewer-scoped key) tries to insert, delete, define a rule, or run a read-then-write program; each gets an error frame with code access_denied and the original 'Permission… ✅ pass live refusals-transcript.txt, lines tagged [S1 role]
A non-admin runs admin-only commands (.compact, .user create, .user list, .kg drop, .apikey revoke, .kg acl grant, .kg use _internal, and .kg create on a scoped key); each is refused w… ✅ pass live refusals-transcript.txt, lines tagged [S2 admin-only]
A writer or decider key granted only claim writes stock, mixes a granted and ungranted write, or switches to another graph; each is refused with access_denied naming the missing grant or key sco… ✅ pass live refusals-transcript.txt, lines tagged [S3 grants], including the admin read-back of stock and claim
A key scoped to tenant_a, a user with no grant on tenant_b, and an admin targeting _internal each connect and get an auth_error frame with code access_denied ✅ pass live refusals-transcript.txt, lines tagged [S4 auth_error]
Adversarial: an unknown API key and a wrong password get an auth_error with no code field, so failed credentials are not reported as permission refusals ✅ pass live refusals-transcript.txt, [S4 auth_error] ADVERSARIAL lines ('Invalid API key', 'Invalid credentials', no code)
A user with a live subscription has their grant revoked; the next write pushes a subscription_error with code access_denied and the unchanged 'keeps its last delivered result' message ✅ pass live refusals-transcript.txt, [S5 revoked] push line
After the grant is revoked, the same connection's query, .subscribe, read frame and subscribe frame are each refused with an error frame coded access_denied ✅ pass live refusals-transcript.txt, remaining [S5 revoked] lines
Adversarial: a malformed program from an admin and from a scoped key is answered with validation, not access_denied, and the admin can run the .compact the viewer was refused ✅ pass live refusals-transcript.txt, lines tagged [S6 other errors]
An operator scrapes /metrics/prometheus and sees the refusals counted under inputlayer_ws_errors_total{code="access_denied"} ✅ pass live refusals-transcript.txt, [S7 metrics]: counter reads 20, equal to the refused error frames received in the run
The S15 multi-tenant scenario asserts access_denied directly on the writer key's refused write, refused graph switch and refused connect to tenant_b, with no known-defect waiver ✅ pass live cargo test --test scenarios s15_tenants_and_scoped_keys_are_isolated — 1 passed
The Python SDK parses auth_error and subscription_error frames with and without the new optional code field ⏸️ untested no The prior payload did not establish a live result: the only check recorded was pytest tests/test_protocol.py in packages/inputlayer-py (37 passed), a unit test of the SDK deserializer that was not d…
Evidence: Live wire transcript of every refusal driven against the running server
setup: graphs tenant_a/tenant_b, user bob (viewer on tenant_a), keys writer-a/decider-a (claim only), viewer-a

[S1 role] PASS bob (viewer) logs in on tenant_a
      <- {"type":"authenticated"}
[S1 role] PASS viewer inserts a fact: +stock[("i2", 1)]
      <- {"type":"error","code":"access_denied","message":"Permission denied: you have viewer access to this knowledge graph"}
[S1 role] PASS viewer runs a read then a write in one program
      <- {"type":"error","code":"access_denied","message":"Permission denied: you have viewer access to this knowledge graph"}
[S1 role] PASS viewer-scoped key deletes a fact
      <- {"type":"error","code":"access_denied","message":"Permission denied: you have viewer access to this knowledge graph"}
[S1 role] PASS viewer-scoped key defines a rule
      <- {"type":"error","code":"access_denied","message":"Permission denied: you have viewer access to this knowledge graph"}
[S2 admin-only] PASS viewer runs .compact
      <- {"type":"error","code":"access_denied","message":"Permission denied: only admins can compact"}
[S2 admin-only] PASS viewer runs .user create mallory mallory-password-1 admin
      <- {"type":"error","code":"access_denied","message":"Permission denied: only admins can manage users"}
[S2 admin-only] PASS viewer runs .user list
      <- {"type":"error","code":"access_denied","message":"Permission denied: only admins can manage users"}
[S2 admin-only] PASS viewer runs .kg drop tenant_b
      <- {"type":"error","code":"access_denied","message":"Access denied"}
[S2 admin-only] PASS viewer runs .apikey revoke writer-a
      <- {"type":"error","code":"access_denied","message":"Permission denied: only admins can manage API keys"}
[S2 admin-only] PASS viewer runs .kg acl grant tenant_b bob owner
      <- {"type":"error","code":"access_denied","message":"Access denied"}
[S2 admin-only] PASS viewer switches to the system graph: .kg use _internal
      <- {"type":"error","code":"access_denied","message":"Access denied: '_internal' is a system knowledge graph"}
[S2 admin-only] PASS viewer-scoped key creates a knowledge graph
      <- {"type":"error","code":"access_denied","message":"Permission denied: this API key is scoped to knowledge graph 'tenant_a' and cannot create one"}
[S3 grants] PASS writer-a key (claim only) authenticates on tenant_a
      <- {"type":"authenticated"}
[S3 grants] PASS writer-a writes its granted relation claim
      <- {"type":"result"}
[S3 grants] PASS writer-a writes ungranted relation stock
      <- {"type":"error","code":"access_denied","message":"Permission denied: the writer role on 'tenant_a' has no write grant for relation 'stock'"}
[S3 grants] PASS writer-a mixes a granted and an ungranted write
      <- {"type":"error","code":"access_denied","message":"Permission denied: the writer role on 'tenant_a' has no write grant for relation 'stock'"}
[S3 grants] PASS writer-a switches to tenant_b
      <- {"type":"error","code":"access_denied","message":"Access denied: this API key is scoped to knowledge graph 'tenant_a'"}
[S3 grants] PASS decider-a writes ungranted relation stock
      <- {"type":"error","code":"access_denied","message":"Permission denied: the decider role on 'tenant_a' has no write grant for relation 'stock'"}
[S3 grants] PASS nothing of the refused programs applied
      <- stock=[["i1",5]] claim=[["o-1","i1"],["o-2","i1"]]
[S4 auth_error] PASS writer-a key (scoped to tenant_a) connects to tenant_b
      <- {"type":"auth_error","code":"access_denied","message":"Access denied"}
[S4 auth_error] PASS bob (no grant on tenant_b) logs in on tenant_b
      <- {"type":"auth_error","code":"access_denied","message":"Access denied"}
[S4 auth_error] PASS admin connects to the system graph _internal
      <- {"type":"auth_error","code":"access_denied","message":"Access denied: '_internal' is a system knowledge graph"}
[S4 auth_error] PASS ADVERSARIAL unknown API key: failed credential, not a permission refusal
      <- {"type":"auth_error","message":"Invalid API key"}
[S4 auth_error] PASS ADVERSARIAL wrong password: failed credential, not a permission refusal
      <- {"type":"auth_error","message":"Invalid credentials"}
[S5 revoked] PASS bob subscribes while he still has access
      <- {"type":"result"}
[S5 revoked] PASS push on bob's live subscription after his grant is revoked
      <- {"type":"subscription_error","code":"access_denied","message":"Access denied to knowledge graph 'tenant_a'. The subscription keeps its last delivered result; once access is restored, its next delta is relative to it."}
[S5 revoked] PASS bob queries after revocation: ?stock(I, Q)
      <- {"type":"error","code":"access_denied","message":"Access denied"}
[S5 revoked] PASS bob opens a subscription via .subscribe
      <- {"type":"error","code":"access_denied","message":"Access denied"}
[S5 revoked] PASS bob sends a `read` frame
      <- {"type":"error","code":"access_denied","message":"Access denied"}
[S5 revoked] PASS bob sends a `subscribe` frame
      <- {"type":"error","code":"access_denied","message":"Access denied"}
[S6 other errors] PASS ADVERSARIAL admin sends a malformed program
      <- {"type":"error","code":"validation","message":"Program has 1 parse error(s)"}
[S6 other errors] PASS ADVERSARIAL scoped key sends a malformed query
      <- {"type":"error","code":"validation","message":"Program has 1 parse error(s)"}
[S6 other errors] PASS admin runs what the viewer was refused: .compact
      <- {"type":"result"}
[S7 metrics] PASS /metrics/prometheus counts the refusals under code="access_denied" (HTTP 200; 20 access_denied error frames received)
      <- inputlayer_ws_errors_total{code="access_denied"} 20

35/35 checks passed
Evidence: Per-check results with the received frames (JSON)
[
  {
    "scenario": "S1 role",
    "label": "bob (viewer) logs in on tenant_a",
    "ok": true,
    "frame": {
      "type": "authenticated"
    },
    "problems": []
  },
  {
    "scenario": "S1 role",
    "label": "viewer inserts a fact: +stock[(\"i2\", 1)]",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: you have viewer access to this knowledge graph"
    },
    "problems": []
  },
  {
    "scenario": "S1 role",
    "label": "viewer runs a read then a write in one program",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: you have viewer access to this knowledge graph"
    },
    "problems": []
  },
  {
    "scenario": "S1 role",
    "label": "viewer-scoped key deletes a fact",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: you have viewer access to this knowledge graph"
    },
    "problems": []
  },
  {
    "scenario": "S1 role",
    "label": "viewer-scoped key defines a rule",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: you have viewer access to this knowledge graph"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer runs .compact",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: only admins can compact"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer runs .user create mallory mallory-password-1 admin",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: only admins can manage users"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer runs .user list",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: only admins can manage users"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer runs .kg drop tenant_b",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer runs .apikey revoke writer-a",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: only admins can manage API keys"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer runs .kg acl grant tenant_b bob owner",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer switches to the system graph: .kg use _internal",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied: '_internal' is a system knowledge graph"
    },
    "problems": []
  },
  {
    "scenario": "S2 admin-only",
    "label": "viewer-scoped key creates a knowledge graph",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: this API key is scoped to knowledge graph 'tenant_a' and cannot create one"
    },
    "problems": []
  },
  {
    "scenario": "S3 grants",
    "label": "writer-a key (claim only) authenticates on tenant_a",
    "ok": true,
    "frame": {
      "type": "authenticated"
    },
    "problems": []
  },
  {
    "scenario": "S3 grants",
    "label": "writer-a writes its granted relation claim",
    "ok": true,
    "frame": {
      "type": "result"
    },
    "problems": []
  },
  {
    "scenario": "S3 grants",
    "label": "writer-a writes ungranted relation stock",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: the writer role on 'tenant_a' has no write grant for relation 'stock'"
    },
    "problems": []
  },
  {
    "scenario": "S3 grants",
    "label": "writer-a mixes a granted and an ungranted write",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: the writer role on 'tenant_a' has no write grant for relation 'stock'"
    },
    "problems": []
  },
  {
    "scenario": "S3 grants",
    "label": "writer-a switches to tenant_b",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied: this API key is scoped to knowledge graph 'tenant_a'"
    },
    "problems": []
  },
  {
    "scenario": "S3 grants",
    "label": "decider-a writes ungranted relation stock",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Permission denied: the decider role on 'tenant_a' has no write grant for relation 'stock'"
    },
    "problems": []
  },
  {
    "scenario": "S3 grants",
    "label": "refused programs applied nothing",
    "ok": true,
    "frame": {
      "stock": [
        [
          "i1",
          5
        ]
      ],
      "claim": [
        [
          "o-1",
          "i1"
        ],
        [
          "o-2",
          "i1"
        ]
      ]
    },
    "problems": []
  },
  {
    "scenario": "S4 auth_error",
    "label": "writer-a key (scoped to tenant_a) connects to tenant_b",
    "ok": true,
    "frame": {
      "type": "auth_error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S4 auth_error",
    "label": "bob (no grant on tenant_b) logs in on tenant_b",
    "ok": true,
    "frame": {
      "type": "auth_error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S4 auth_error",
    "label": "admin connects to the system graph _internal",
    "ok": true,
    "frame": {
      "type": "auth_error",
      "code": "access_denied",
      "message": "Access denied: '_internal' is a system knowledge graph"
    },
    "problems": []
  },
  {
    "scenario": "S4 auth_error",
    "label": "ADVERSARIAL unknown API key: failed credential, not a permission refusal",
    "ok": true,
    "frame": {
      "type": "auth_error",
      "message": "Invalid API key"
    },
    "problems": []
  },
  {
    "scenario": "S4 auth_error",
    "label": "ADVERSARIAL wrong password: failed credential, not a permission refusal",
    "ok": true,
    "frame": {
      "type": "auth_error",
      "message": "Invalid credentials"
    },
    "problems": []
  },
  {
    "scenario": "S5 revoked",
    "label": "bob subscribes while he still has access",
    "ok": true,
    "frame": {
      "type": "result"
    },
    "problems": []
  },
  {
    "scenario": "S5 revoked",
    "label": "push on bob's live subscription after his grant is revoked",
    "ok": true,
    "frame": {
      "type": "subscription_error",
      "code": "access_denied",
      "message": "Access denied to knowledge graph 'tenant_a'. The subscription keeps its last delivered result; once access is restored, its next delta is relative to it."
    },
    "problems": []
  },
  {
    "scenario": "S5 revoked",
    "label": "bob queries after revocation: ?stock(I, Q)",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S5 revoked",
    "label": "bob opens a subscription via .subscribe",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S5 revoked",
    "label": "bob sends a `read` frame",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S5 revoked",
    "label": "bob sends a `subscribe` frame",
    "ok": true,
    "frame": {
      "type": "error",
      "code": "access_denied",
      "message": "Access denied"
    },
    "problems": []
  },
  {
    "scenario": "S6 other errors",
    "label": "malformed program is not access_denied",
    "ok": true,
    "frame": {
      "type": "error",
      "id": "2",
      "message": "Program has 1 parse error(s)",
      "validation_errors": [
        {
          "line": 1,
          "statement_index": 0,
          "error": "Bulk insert must be in format: relation[(t1), (t2), ...]"
        }
      ],
      "code": "validation"
    },
    "problems": []
  },
  {
    "scenario": "S6 other errors",
    "label": "scoped key malformed query is not access_denied",
    "ok": true,
    "frame": {
      "type": "error",
      "id": "2",
      "message": "Program has 1 parse error(s)",
      "validation_errors": [
        {
          "line": 1,
          "statement_index": 0,
          "error": "Invalid atom: nope("
        }
      ],
      "code": "validation"
    },
    "problems": []
  },
  {
    "scenario": "S6 other errors",
    "label": "admin runs what the viewer was refused: .compact",
    "ok": true,
    "frame": {
      "type": "result"
    },
    "problems": []
  },
  {
    "scenario": "S7 metrics",
    "label": "ws_errors_total{code=access_denied}",
    "ok": true,
    "frame": {
      "line": "inputlayer_ws_errors_total{code=\"access_denied\"} 20",
      "errorFrames": 20
    },
    "problems": []
  }
]
Evidence: Driver script used against the disposable server
// Drives a real inputlayer-server over /ws and records every permission
// refusal frame. Usage: node drive.mjs <port> <admin-api-key> <admin-password>
const [port, adminKey, adminPassword] = process.argv.slice(2);
const log = [];
const say = (line) => {
  log.push(line);
  console.log(line);
};

class Conn {
  constructor(ws) {
    this.ws = ws;
    this.frames = [];
    this.waiters = [];
    this.nextId = 0;
    this.closed = false;
    ws.onmessage = (event) => {
      this.frames.push(JSON.parse(event.data));
      this.wake();
    };
    ws.onclose = () => {
      this.closed = true;
      this.wake();
    };
  }
  wake() {
    for (const waiter of this.waiters.splice(0)) waiter();
  }
  async take(match, what, timeoutMs = 10000) {
    const deadline = Date.now() + timeoutMs;
    for (;;) {
      const at = this.frames.findIndex(match);
      if (at >= 0) return this.frames.splice(at, 1)[0];
      if (this.closed) throw new Error(`closed while waiting for ${what}`);
      const left = deadline - Date.now();
      if (left <= 0) throw new Error(`timeout waiting for ${what}`);
      await new Promise((resolve) => {
        this.waiters.push(resolve);
        setTimeout(resolve, left);
      });
    }
  }
  async request(body) {
    const id = String(++this.nextId);
    this.ws.send(JSON.stringify({ ...body, id }));
    return this.take((f) => f.id === id, `reply to ${JSON.stringify(body)}`);
  }
  execute(program) {
    return this.request({ type: "execute", program });
  }
  close() {
    this.ws.close();
  }
}

// Opens /ws?kg=<kg> and logs in; returns [conn, first reply].
async function connect(kg, login) {
  const ws = new WebSocket(`ws://127.0.0.1:${port}/ws?kg=${kg}`);
  await new Promise((resolve, reject) => {
    ws.onopen = resolve;
    ws.onerror = () => reject(new Error(`cannot open /ws?kg=${kg}`));
  });
  const conn = new Conn(ws);
  const reply = await conn.request(login);
  return [conn, reply];
}
const byKey = (key) => ({ type: "authenticate", api_key: key });
const byPassword = (username, password) => ({ type: "login", username, password });

async function admin(kg, program) {
  const [conn, auth] = await connect(kg, byKey(adminKey));
  if (auth.type !== "authenticated") throw new Error(`admin auth: ${JSON.stringify(auth)}`);
  const reply = await conn.execute(program);
  conn.close();
  if (reply.type !== "result") throw new Error(`${program}: ${JSON.stringify(reply)}`);
  return reply;
}

const results = [];
function check(scenario, label, frame, want) {
  const problems = [];
  for (const [field, expected] of Object.entries(want)) {
    const actual = frame[field];
    if (expected === undefined) {
      if (field in frame) problems.push(`${field} present (${JSON.stringify(actual)}), expected absent`);
    } else if (expected instanceof RegExp) {
      if (typeof actual !== "string" || !expected.test(actual)) problems.push(`${field}=${JSON.stringify(actual)} !~ ${expected}`);
    } else if (actual !== expected) {
      problems.push(`${field}=${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`);
    }
  }
  const ok = problems.length === 0;
  const shown = { type: frame.type, code: frame.code, message: frame.message };
  say(`[${scenario}] ${ok ? "PASS" : "FAIL"} ${label}\n      <- ${JSON.stringify(shown)}${ok ? "" : `\n      !! ${problems.join("; ")}`}`);
  results.push({ scenario, label, ok, frame: shown, problems });
}

async function metric(name) {
  const response = await fetch(`http://127.0.0.1:${port}/metrics/prometheus`, {
    headers: { authorization: `Bearer ${adminKey}` },
  });
  const text = await response.text();
  return { status: response.status, lines: text.split("\n").filter((l) => l.startsWith(name)) };
}

const DENIED = "access_denied";

// ---- Setup: two graphs, a viewer user, scoped keys -------------------------
await admin("default", ".kg create tenant_a");
await admin("default", ".kg create tenant_b");
await admin("tenant_a", '+claim[("o-1", "i1")]');
await admin("tenant_a", '+stock[("i1", 5)]');
await admin("tenant_b", '+stock[("i9", 1)]');
await admin("default", ".user create bob bob-password-123 viewer");
await admin("default", ".kg acl grant tenant_a bob viewer");
const keyOf = (reply) => reply.rows[0][1];
const writerKey = keyOf(await admin("default", ".apikey create writer-a role writer on tenant_a relations claim"));
const deciderKey = keyOf(await admin("default", ".apikey create decider-a role decider on tenant_a relations claim"));
const viewerKey = keyOf(await admin("default", ".apikey create viewer-a role viewer on tenant_a"));
say("setup: graphs tenant_a/tenant_b, user bob (viewer on tenant_a), keys writer-a/decider-a (claim only), viewer-a\n");

// ---- S1: role refusals ------------------------------------------------------
{
  const [bob, auth] = await connect("tenant_a", byPassword("bob", "bob-password-123"));
  check("S1 role", "bob (viewer) logs in on tenant_a", auth, { type: "authenticated" });
  check("S1 role", "viewer inserts a fact: +stock[(\"i2\", 1)]", await bob.execute('+stock[("i2", 1)]'), {
    type: "error", code: DENIED, message: /^Permission denied: you have viewer access/,
  });
  check("S1 role", "viewer runs a read then a write in one program", await bob.execute('?stock(I, Q)\n+stock[("i2", 1)]'), {
    type: "error", code: DENIED, message: /^Permission denied/,
  });
  const [viewer] = await connect("tenant_a", byKey(viewerKey));
  check("S1 role", "viewer-scoped key deletes a fact", await viewer.execute('-stock[("i1", 5)]'), {
    type: "error", code: DENIED, message: /denied/i,
  });
  check("S1 role", "viewer-scoped key defines a rule", await viewer.execute("+low(I) <- stock(I, Q), Q < 2"), {
    type: "error", code: DENIED, message: /denied/i,
  });

  // ---- S2: admin-only commands -------------------------------------------
  for (const command of [".compact", ".user create mallory mallory-password-1 admin", ".user list", ".kg drop tenant_b", ".apikey revoke writer-a", ".kg acl grant tenant_b bob owner"]) {
    check("S2 admin-only", `viewer runs ${command}`, await bob.execute(command), { type: "error", code: DENIED, message: /denied|only admins/i });
  }
  check("S2 admin-only", "viewer switches to the system graph: .kg use _internal", await bob.execute(".kg use _internal"), {
    type: "error", code: DENIED, message: "Access denied: '_internal' is a system knowledge graph",
  });
  check("S2 admin-only", "viewer-scoped key creates a knowledge graph", await viewer.execute(".kg create tenant_c"), {
    type: "error", code: DENIED, message: /denied/i,
  });
  bob.close();
  viewer.close();
}

// ---- S3: per-relation grants and key scope ---------------------------------
{
  const [writer, auth] = await connect("tenant_a", byKey(writerKey));
  check("S3 grants", "writer-a key (claim only) authenticates on tenant_a", auth, { type: "authenticated" });
  check("S3 grants", "writer-a writes its granted relation claim", await writer.execute('+claim[("o-2", "i1")]'), { type: "result" });
  check("S3 grants", "writer-a writes ungranted relation stock", await writer.execute('+stock[("i1", 9)]'), {
    type: "error", code: DENIED, message: /no write grant for relation 'stock'/,
  });
  check("S3 grants", "writer-a mixes a granted and an ungranted write", await writer.execute('+claim[("o-3", "i1")]\n+stock[("i1", 9)]'), {
    type: "error", code: DENIED, message: /no write grant for relation 'stock'/,
  });
  check("S3 grants", "writer-a switches to tenant_b", await writer.execute(".kg use tenant_b"), {
    type: "error", code: DENIED, message: /scoped to knowledge graph 'tenant_a'/,
  });
  const [decider] = await connect("tenant_a", byKey(deciderKey));
  check("S3 grants", "decider-a writes ungranted relation stock", await decider.execute('-stock[("i1", 5)]'), {
    type: "error", code: DENIED, message: /no write grant for relation 'stock'/,
  });
  const stock = await admin("tenant_a", "?stock(I, Q)");
  const claims = await admin("tenant_a", "?claim(O, I)");
  const intact = JSON.stringify(stock.rows) === JSON.stringify([["i1", 5]]) && !JSON.stringify(claims.rows).includes("o-3");
  say(`[S3 grants] ${intact ? "PASS" : "FAIL"} nothing of the refused programs applied\n      <- stock=${JSON.stringify(stock.rows)} claim=${JSON.stringify(claims.rows)}`);
  results.push({ scenario: "S3 grants", label: "refused programs applied nothing", ok: intact, frame: { stock: stock.rows, claim: claims.rows }, problems: [] });
  writer.close();
  decider.close();
}

// ---- S4: auth_error on connect ---------------------------------------------
{
  const [a, scoped] = await connect("tenant_b", byKey(writerKey));
  check("S4 auth_error", "writer-a key (scoped to tenant_a) connects to tenant_b", scoped, { type: "auth_error", code: DENIED, message: "Access denied" });
  const [b, ungranted] = await connect("tenant_b", byPassword("bob", "bob-password-123"));
  check("S4 auth_error", "bob (no grant on tenant_b) logs in on tenant_b", ungranted, { type: "auth_error", code: DENIED, message: "Access denied" });
  const [c, internal] = await connect("_internal", byKey(adminKey));
  check("S4 auth_error", "admin connects to the system graph _internal", internal, {
    type: "auth_error", code: DENIED, message: "Access denied: '_internal' is a system knowledge graph",
  });
  const [d, badKey] = await connect("tenant_a", byKey("il_not_a_real_key"));
  check("S4 auth_error", "ADVERSARIAL unknown API key: failed credential, not a permission refusal", badKey, { type: "auth_error", code: undefined });
  const [e, badPassword] = await connect("tenant_a", byPassword("bob", "wrong-password"));
  check("S4 auth_error", "ADVERSARIAL wrong password: failed credential, not a permission refusal", badPassword, { type: "auth_error", code: undefined });
  for (const conn of [a, b, c, d, e]) conn.close();
}

// ---- S5: grant revoked on a live connection ---------------------------------
{
  const [bob] = await connect("tenant_a", byPassword("bob", "bob-password-123"));
  check("S5 revoked", "bob subscribes while he still has access", await bob.execute(".subscribe live ?stock(I, Q)"), { type: "result" });
  await admin("default", ".kg acl revoke tenant_a bob");
  await admin("tenant_a", '+stock[("i3", 7)]');
  const push = await bob.take((f) => f.type === "subscription_error", "subscription_error push");
  check("S5 revoked", "push on bob's live subscription after his grant is revoked", push, {
    type: "subscription_error", code: DENIED, message: /^Access denied to knowledge graph 'tenant_a'\. The subscription keeps its last delivered result/,
  });
  check("S5 revoked", "bob queries after revocation: ?stock(I, Q)", await bob.execute("?stock(I, Q)"), { type: "error", code: DENIED, message: "Access denied" });
  check("S5 revoked", "bob opens a subscription via .subscribe", await bob.execute(".subscribe again ?stock(I, Q)"), { type: "error", code: DENIED, message: "Access denied" });
  check("S5 revoked", "bob sends a `read` frame", await bob.request({ type: "read", queries: [{ name: "s", query: "?stock(I, Q)" }] }), {
    type: "error", code: DENIED, message: /Access denied/,
  });
  check("S5 revoked", "bob sends a `subscribe` frame", await bob.request({ type: "subscribe", subscription: "grp", queries: [{ name: "s", query: "?stock(I, Q)" }] }), {
    type: "error", code: DENIED, message: /Access denied/,
  });
  bob.close();
}

// ---- S6: non-permission failures keep their own code ------------------------
{
  const [adminConn] = await connect("tenant_a", byKey(adminKey));
  const syntax = await adminConn.execute("+stock[(");
  const notDenied = syntax.type === "error" && syntax.code !== DENIED;
  say(`[S6 other errors] ${notDenied ? "PASS" : "FAIL"} ADVERSARIAL admin sends a malformed program\n      <- ${JSON.stringify({ type: syntax.type, code: syntax.code, message: syntax.message })}`);
  results.push({ scenario: "S6 other errors", label: "malformed program is not access_denied", ok: notDenied, frame: syntax, problems: [] });
  const [writer] = await connect("tenant_a", byKey(writerKey));
  const writerSyntax = await writer.execute("?nope(");
  const ok = writerSyntax.type === "error" && writerSyntax.code !== DENIED;
  say(`[S6 other errors] ${ok ? "PASS" : "FAIL"} ADVERSARIAL scoped key sends a malformed query\n      <- ${JSON.stringify({ type: writerSyntax.type, code: writerSyntax.code, message: writerSyntax.message })}`);
  results.push({ scenario: "S6 other errors", label: "scoped key malformed query is not access_denied", ok, frame: writerSyntax, problems: [] });
  check("S6 other errors", "admin runs what the viewer was refused: .compact", await adminConn.execute(".compact"), { type: "result" });
  adminConn.close();
  writer.close();
}

// ---- S7: metrics ------------------------------------------------------------
{
  const { status, lines } = await metric("inputlayer_ws_errors_total");
  const denied = lines.find((l) => l.includes('code="access_denied"'));
  const count = denied ? Number(denied.trim().split(/\s+/).pop()) : NaN;
  const errorFrames = results.filter((r) => r.frame.type === "error" && r.frame.code === DENIED).length;
  const ok = status === 200 && count === errorFrames;
  say(`[S7 metrics] ${ok ? "PASS" : "FAIL"} /metrics/prometheus counts the refusals under code="access_denied" (HTTP ${status}; ${errorFrames} access_denied error frames received)\n      <- ${denied ?? "(no access_denied series)"}`);
  results.push({ scenario: "S7 metrics", label: "ws_errors_total{code=access_denied}", ok, frame: { line: denied, errorFrames }, problems: [] });
}

const failed = results.filter((r) => !r.ok);
say(`\n${results.length - failed.length}/${results.length} checks passed`);
const fs = await import("node:fs");
fs.writeFileSync(new URL("./refusals.json", import.meta.url), JSON.stringify(results, null, 2));
fs.writeFileSync(new URL("./refusals-transcript.txt", import.meta.url), log.join("\n") + "\n");
process.exit(failed.length ? 1 : 0);
Evidence: Server log from the disposable instance
InputLayer Server
=================

WARNING: ws_max_messages_per_sec = 0 (unlimited). No WebSocket message rate limit is enforced. Set a finite value for production.
WARNING: per_ip_max_rps = 0 (unlimited). No per-IP request rate limit is enforced. Set a finite value for production.

=== INITIAL ADMIN CREDENTIALS CREATED ===
Admin API key: ****e47c
==========================================

Generated credentials saved to: /tmp/il364-C2YvGr/credentials.toml
Retrieve them with:  cat /tmp/il364-C2YvGr/credentials.toml
Delete this file to generate new credentials on next boot.

Storage engine initialized

HTTP Server
-----------
Address: 127.0.0.1:55221

HTTP server listening on: http://127.0.0.1:55221
WebSocket API docs at: http://127.0.0.1:55221/api/ws-docs
^[[2m2026-10-05T22:39:07.128775Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::rest::handlers::ws^[[0m^[[2m:^[[0m ws_session_create_failed ^[[3mkg^[[0m^[[2m=^[[0mtenant_b ^[[3merror^[[0m^[[2m=^[[0mAccess denied
^[[2m2026-10-05T22:39:07.507427Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::rest::handlers::ws^[[0m^[[2m:^[[0m ws_session_create_failed ^[[3mkg^[[0m^[[2m=^[[0mtenant_b ^[[3merror^[[0m^[[2m=^[[0mAccess denied
^[[2m2026-10-05T22:39:07.509662Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::rest::handlers::ws^[[0m^[[2m:^[[0m ws_session_create_failed ^[[3mkg^[[0m^[[2m=^[[0m_internal ^[[3merror^[[0m^[[2m=^[[0mAccess denied: '_internal' is a system knowledge graph
^[[2m2026-10-05T22:39:07.510960Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::handler^[[0m^[[2m:^[[0m audit_auth_apikey_rejected ^[[3mreason^[[0m^[[2m=^[[0mInvalid API key
^[[2m2026-10-05T22:39:07.510983Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::rest::handlers::ws::auth^[[0m^[[2m:^[[0m ws_apikey_auth_failed ^[[3mpeer^[[0m^[[2m=^[[0m127.0.0.1
^[[2m2026-10-05T22:39:07.882404Z^[[0m ^[[33m WARN^[[0m ^[[2minputlayer::protocol::handler^[[0m^[[2m:^[[0m audit_auth_login_failed ^[[3musername^[[0m^[[2m=^[[0mbob ^[[3mpeer^[[0m^[[2m=^[[0m127.0.0.1
Evidence: Representative frames received from the running server
viewer inserts a fact
<- {"type":"error","code":"access_denied","message":"Permission denied: you have viewer access to this knowledge graph"}
viewer runs .compact
<- {"type":"error","code":"access_denied","message":"Permission denied: only admins can compact"}
writer-a (claim only) writes stock
<- {"type":"error","code":"access_denied","message":"Permission denied: the writer role on 'tenant_a' has no write grant for relation 'stock'"}
writer-a switches to tenant_b
<- {"type":"error","code":"access_denied","message":"Access denied: this API key is scoped to knowledge graph 'tenant_a'"}
writer-a key connects to tenant_b
<- {"type":"auth_error","code":"access_denied","message":"Access denied"}
push on a live subscription after the grant is revoked
<- {"type":"subscription_error","code":"access_denied","message":"Access denied to knowledge graph 'tenant_a'. The subscription keeps its last delivered result; once access is restored, its next delta is relative to it."}
ADVERSARIAL unknown API key
<- {"type":"auth_error","message":"Invalid API key"}
ADVERSARIAL malformed program
<- {"type":"error","code":"validation","message":"Program has 1 parse error(s)"}
metrics
<- inputlayer_ws_errors_total{code="access_denied"} 20

35/35 checks passed

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

🔧 **Rebase** - 8 issues found → auto-fixed ✅
  • ⚠️ TESTING.md - merge conflict rebasing onto origin/main
  • ⚠️ docs/spec/asyncapi.yaml - merge conflict rebasing onto origin/main
  • ⚠️ gui/lib/docs-bundle.ts - merge conflict rebasing onto origin/main
  • ⚠️ gui/lib/ws-types.ts - merge conflict rebasing onto origin/main
  • ⚠️ packages/inputlayer-js/src/protocol.ts - merge conflict rebasing onto origin/main
  • ⚠️ packages/inputlayer-py/src/inputlayer/_protocol.py - merge conflict rebasing onto origin/main
  • ⚠️ ws-protocol/src/error.rs - merge conflict rebasing onto origin/main
  • ⚠️ ws-protocol/src/server/tests.rs - merge conflict rebasing onto origin/main

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

⚠️ **Review** - 2 issues (1 warning, 1 info)
  • ⚠️ src/protocol/handler.rs:1368 - Intent: "Add an access_denied code to every permission refusal path (roles, grants, admin-only commands)". Every refusal answered by an error frame is now coded (programs, read, subscribe, .subscribe), but permission refusals delivered on other frame types still carry a message only: (1) src/protocol/handler.rs:1368 create_session_with_auth returns a bare "Access denied" (and :1364 the revoked/expired credential text), sent by src/protocol/rest/handlers/ws.rs:350 as auth_error, which has no code field - this is the scoped-key-connects-to-another-graph case S15 still checks by message substring (tests/scenarios/tenancy.rs:80); (2) src/protocol/subscription/subscriber.rs:150 the "Access denied to knowledge graph ..." subscription_error push sent when read access is lost on a live subscription, whose frame (ws-protocol/src/push.rs:210) has no code either. Coding these means adding a code field to the auth_error and subscription_error wire frames, spec and SDK types - the remedy, not the defect, needs authorization. Decide whether those two frames are in scope for Permission refusals carry no structured error code (access_denied) #364 or a follow-up.
  • ℹ️ src/protocol/rest/asyncapi_docs.html:203 - The recorded rebase decision requires every code list to list both replica_unconfirmed and access_denied. Two lists edited by this change gained access_denied but still omit replica_unconfirmed (an omission inherited from feat(replication): synchronous shipping so acknowledged writes survive losing the primary #367): src/protocol/rest/asyncapi_docs.html:203 (... precondition_failed or access_denied) and the code paragraph in docs/content/docs/guides/websocket-api.mdx:237. Add replica_unconfirmed before access_denied in both and regenerate gui/lib/docs-bundle.ts with node gui/scripts/bundle-docs.mjs from gui/.
  • ℹ️ tests/credential_revocation_tests.rs:708 - The new test permission_refusals_reply_with_the_access_denied_code was inserted between admin_execute and its doc comment, so the line "Run program over an admin /ws session; its result frame." now leads the new test's doc and admin_execute is undocumented. Move that line back above async fn admin_execute.

🔧 Fix applied.
2 issues (1 warning, 1 info) still open:

  • ⚠️ src/protocol/handler.rs:1368 - Intent: "Add an access_denied code to every permission refusal path (roles, grants, admin-only commands)". Every refusal answered by an error frame is now coded (programs, read, subscribe, .subscribe), but permission refusals delivered on other frame types still carry a message only: (1) src/protocol/handler.rs:1368 create_session_with_auth returns a bare "Access denied" (and :1364 the revoked/expired credential text), sent by src/protocol/rest/handlers/ws.rs:350 as auth_error, which has no code field - this is the scoped-key-connects-to-another-graph case S15 still checks by message substring (tests/scenarios/tenancy.rs:80); (2) src/protocol/subscription/subscriber.rs:150 the "Access denied to knowledge graph ..." subscription_error push sent when read access is lost on a live subscription, whose frame (ws-protocol/src/push.rs:210) has no code either. Coding these means adding a code field to the auth_error and subscription_error wire frames, spec and SDK types - the remedy, not the defect, needs authorization. Decide whether those two frames are in scope for Permission refusals carry no structured error code (access_denied) #364 or a follow-up.
  • ℹ️ src/protocol/handler.rs:1361 - Fix round 1 changed create_session_with_auth to return ProgramError, so principal.identity()? now maps a revoked/expired credential to access_denied on the auth_error frame (via From&lt;CredentialEnded&gt;), while docs/spec/asyncapi.yaml:1916 describes the code there as only for a valid credential that may not use the knowledge graph ("Absent for failed credentials"). It is reachable only when the credential ends in the window between the principal.identity() check at src/protocol/rest/handlers/ws.rs:329 (which answers an uncoded "Invalid credentials") and session creation, and it matches the code the same refusal already carries on error frames and in docs/spec/errors.md, so it stays within the recorded decision ("keeps whatever code fits it today or none"). No action needed.
✅ **Test** - passed

✅ No issues found.

  • Live validation: ✅ go - 10 of 11 scenarios driven live against the product
Scenario Result Live Evidence
A viewer (user login and viewer-scoped key) tries to insert, delete, define a rule, or run a read-then-write program; each gets an error frame with code access_denied and the original 'Permission… ✅ pass live refusals-transcript.txt, lines tagged [S1 role]
A non-admin runs admin-only commands (.compact, .user create, .user list, .kg drop, .apikey revoke, .kg acl grant, .kg use _internal, and .kg create on a scoped key); each is refused w… ✅ pass live refusals-transcript.txt, lines tagged [S2 admin-only]
A writer or decider key granted only claim writes stock, mixes a granted and ungranted write, or switches to another graph; each is refused with access_denied naming the missing grant or key sco… ✅ pass live refusals-transcript.txt, lines tagged [S3 grants], including the admin read-back of stock and claim
A key scoped to tenant_a, a user with no grant on tenant_b, and an admin targeting _internal each connect and get an auth_error frame with code access_denied ✅ pass live refusals-transcript.txt, lines tagged [S4 auth_error]
Adversarial: an unknown API key and a wrong password get an auth_error with no code field, so failed credentials are not reported as permission refusals ✅ pass live refusals-transcript.txt, [S4 auth_error] ADVERSARIAL lines ('Invalid API key', 'Invalid credentials', no code)
A user with a live subscription has their grant revoked; the next write pushes a subscription_error with code access_denied and the unchanged 'keeps its last delivered result' message ✅ pass live refusals-transcript.txt, [S5 revoked] push line
After the grant is revoked, the same connection's query, .subscribe, read frame and subscribe frame are each refused with an error frame coded access_denied ✅ pass live refusals-transcript.txt, remaining [S5 revoked] lines
Adversarial: a malformed program from an admin and from a scoped key is answered with validation, not access_denied, and the admin can run the .compact the viewer was refused ✅ pass live refusals-transcript.txt, lines tagged [S6 other errors]
An operator scrapes /metrics/prometheus and sees the refusals counted under inputlayer_ws_errors_total{code=&#34;access_denied&#34;} ✅ pass live refusals-transcript.txt, [S7 metrics]: counter reads 20, equal to the refused error frames received in the run
The S15 multi-tenant scenario asserts access_denied directly on the writer key's refused write, refused graph switch and refused connect to tenant_b, with no known-defect waiver ✅ pass live cargo test --test scenarios s15_tenants_and_scoped_keys_are_isolated — 1 passed
The Python SDK parses auth_error and subscription_error frames with and without the new optional code field ⏸️ untested no The prior payload did not establish a live result: the only check recorded was pytest tests/test_protocol.py in packages/inputlayer-py (37 passed), a unit test of the SDK deserializer that was not d…
  • CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 CARGO_INCREMENTAL=0 cargo build --bin inputlayer-server
  • Started target/debug/inputlayer-server --config &lt;tmp&gt;/engine.toml on a disposable data dir and free port, with a bootstrap admin password and credentials file under the temp dir
  • node ~/.no-mistakes/evidence/01M472W3JM7H4W6RPS4NE9JK6N/drive.mjs &lt;port&gt; &lt;admin-key&gt; &lt;admin-password&gt; — 35/35 checks passed
  • curl http://127.0.0.1:&lt;port&gt;/api/ws-docs — served docs list replica_unconfirmed then access_denied
  • GET /metrics/prometheus with the admin key — inputlayer_ws_errors_total{code=&#34;access_denied&#34;} 20, matching the 20 refused error frames received
  • CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 CARGO_INCREMENTAL=0 cargo test --test scenarios s15_tenants_and_scoped_keys_are_isolated — passed
  • pytest tests/test_protocol.py in packages/inputlayer-py using a throwaway venv under /tmp — 37 passed (unit test only, not a live run)
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 1 info
  • ℹ️ packages/inputlayer-py/tests/test_protocol.py:125 - The new test test_access_denied_code has a 102-character line (ruff E501, limit 100). Left as is: this pass must not edit tests, ruff is not part of the Makefile or CI gates, and the package already has 12 other ruff findings on the base. cargo fmt --check, workspace clippy with -D warnings, and the JS SDK tsc --noEmit all pass; gui eslint was not run (no gui/node_modules installed).
✅ **Push** - passed

✅ No issues found.

Performance gate

Verdict: PASS. Measured on the benchmark host with make perf-gate-remote, run 20261005T225637Z-5556b5be.

  • Candidate: 5556b5beaa639c883b5e59c1d222a3ddf1e0b237, the head of this PR. Baseline: e732cd5258c74d9247e99b2697a0ae080ccf9042. Profile standard, 30 rounds, policy provisional.
  • Every required metric is within its budget. No metric is FAIL or INCONCLUSIVE; 8 non-required p99/p50 series are INVALID for too few baseline samples per round, as in every run of this profile.
  • cheap_query.queries_per_sec: baseline 10168/s, candidate 9921/s, cost ratio 1.024, 95% interval 1.015..1.032, budget 1.05.
  • cheap_query.latency_us: p50 0.412 ms to 0.419 ms (ratio 1.012, interval 0.995..1.031, budget 1.05); p99 0.461 ms to 0.464 ms (ratio 1.004, interval 0.989..1.017, budget 1.10).

An earlier 10-round run (20261005T220357Z-4b027ee0, on this branch before it was rebased and before the review fixes) was INCONCLUSIVE on that one metric: cheap_query.queries_per_sec ratio 1.039, interval 1.018..1.057 against the 1.05 budget. This 30-round run is the rerun perf-gate/README.md asks for.

jsam added 3 commits October 5, 2026 22:29
…#364)

A program or subscription refused for a permission reason was answered
with a message only. Every such refusal now carries the structured code
`access_denied`, on `error` frames as on the handler's `ProgramError`:

- global role (viewers creating knowledge graphs or managing ontologies)
- admin-only commands (.compact, .backup, .user, .apikey)
- role on the knowledge graph (viewer writes, owner-only drop and ACLs,
  no access, the system knowledge graph)
- write grants of the writer and decider roles
- an API key's scope
- a revoked or expired credential
- a subscription refused when it opens, or whose access was lost while
  it opened

Messages are unchanged. The AsyncAPI spec, the error reference and the
JS, Python and GUI code enums list the new code, and S15 asserts it in
place of its known-defect marker.
@jsam
jsam merged commit efa32ce into main Oct 5, 2026
25 checks passed
@jsam
jsam deleted the fm/il-364-access-denied branch October 5, 2026 23:38
jsam added a commit that referenced this pull request Oct 6, 2026
scripts/perf-gate.sh, bench-sessions.sh and bench-views.sh extracted each
baseline with `git archive` and built all of them in one shared
target/perf-gate/build. Cargo names a workspace crate's artifacts by its path
inside the workspace, not by where the workspace is, and rebuilds one only
when a source file is newer than the last build. `git archive` dates every
file at its commit, which is older than any build already in the directory,
so a later baseline reused an earlier one's workspace crates: the build
failed where the APIs had diverged (`missing field code in ServerFrame`
after #387), linked another commit's `ws-protocol` or `ontology-client`
where they had not, and returned the earlier baseline's whole server where
no file had been added or removed between the two commits.

The three scripts now build baselines through scripts/perf-gate-baseline.sh:
each baseline gets a source and target directory of its own under
target/perf-gate/baseline-build/, removed once the binary is cached. The
cache moves to target/perf-gate/baseline-servers/, so a binary cached by the
shared build is never read again, and the three scripts now share one
toolchain key (the gate hashed `rustc -V` without its newline, the
benchmarks with it).

scripts/perf-gate-baseline-selftest.sh builds two baselines that differ in
the root crate and in a path dependency, in both orders, and checks each
binary reports its own commit. It runs in `make pre-pr-selftest` and in the
full suite's perf-gate tool job.
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