Skip to content

fix(gateway): N83 operator contracts — owner-set availability, no silent drops (R0 G8/G9/G11/G12) - #461

Merged
LamaSu merged 7 commits into
masterfrom
fix/kits-n83-operator-contracts
Sep 30, 2026
Merged

LamaSu merged 7 commits into
masterfrom
fix/kits-n83-operator-contracts

Conversation

@LamaSu

@LamaSu LamaSu commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Summary

This is board N83 (steward #3960). The first ADK dress rehearsal (R0, returns/pcc-refvertical-work/rehearsal/R0-findings.md, G8/G9/G11/G12) found four places where an onboarding agent hit a gateway contract that contradicts itself. This PR fixes the code halves; adk documents what is true.

Everything was reproduced before being fixed (goal-root review loop), against master ac86a404 and R0's own call log (rehearsal/r0-run-1/calls.log). The PR is built from master and does not stack on any open PR (WIP rule).

Finding Reproduced Fix
G11: availability cannot be set operator-status.ts:184-197 marks the operator partial and says to set availability. calls.log:1038/1086/1110: PATCH and PUT /api/capabilities/:id return 404, and a re-POST returns the existing row unchanged New PUT /api/capabilities/:capId/availability for the owner only (details below)
G12: create drops fields silently calls.log:371-393: requirementsSchema is sent, and the 201 neither stores it nor says so (CreateCapabilityInput has no such field) The 201 now lists ignoredFields. requirementsSchema gets a hint: typed parameters belong in the capability's CSD. A re-POST says it never updates. Persistence is unchanged: no DDL, since a new column is a parked schema change
G8: register-device setup.ts:547-549 returns a bare missing_required_fields, and setup.ts:614 hard-codes firmware "unknown" (calls.log:511) The 400 now names the missing fields and says deviceId is operator-chosen and stable. A top-level firmware string is stored, trimmed and capped at 64 characters
G9: storage upload message storage.ts:160 says "PUT raw bytes", but there is no PUT route The message says POST. Status and error code are unchanged

G11: the availability route (security notes)

Found while reproducing, NOT changed here (reported to the steward)

  • POST /api/capabilities stores caller-supplied assuranceTiers verbatim, defaulting to [0,1]. Tiers are therefore self-declared unless something else caps them. That is a trust question for evidence/gateway, not a contract bug.
  • The operator-channels routes (POST /api/operators/:slug/channels, PATCH/DELETE /api/operators/channels/:id) have no ownership check at all.

Tests (run on the Spark, worktree wt-kits-n83)

Command Result
packages/gateway: npx vitest run src/__tests__/capability-availability.test.ts 8/8
G11 mutation check: owner check, actor check and delegate guard each disabled in turn 2, 1 and 1 fail; all 8 pass once restored
setup.test.ts, cid-storage.test.ts, capabilities-create-contract.test.ts (new) 61/61
G8/G9/G12 mutation checks: the fix reverted 3/4, 1/1 and 2/4 of the new tests fail (the rest pin pre-existing behaviour)
packages/gateway: npx tsc --noEmit exit 0
full gateway suite 189 files: 2998 passed, 6 skipped (pre-existing), 0 failed

🤖 Generated with Claude Code

LamaSu and others added 7 commits September 29, 2026 16:27
…G11)

operator-status marks an operator 'partial' until each capability has an
availability and tells the operator to set mode=always or windows[], but no
route could set it. PATCH and PUT /api/capabilities/:id do not exist, and a
re-POST returns the existing row unchanged (rehearsal R0, calls.log:1038,
1086, 1110).

New PUT /api/capabilities/:capId/availability:
- The actor is the principal api-gate authenticated (the key's operatorId,
  else the SIWE userId), never a body field. No actor gives 401.
- Only the capability's kernel operatorAddress, under WP-A's fold (never
  true for an empty id), may write; anyone else gets 403.
- The /availability sub-path is deliberate: master's public allowlist
  matches /api/capabilities/:id for every method.
- The body is strict zod (always, windows, cron or manual-claim; HH:mm or
  ISO windows; 5-field cron; an IANA-shaped timezone; no unknown keys),
  stored through serializeAvailability().
- delegate-to-agent and agentEndpoint are refused: that is a URL the
  gateway would POST to (SSRF).

It merges after WP-A #326 (rule 6, owner guard), and should converge on
WP-A's sameIdentity afterwards. Tests: 8/8, including an operator-status
round trip. Mutation checks: with the owner check, the actor check or the
delegate guard disabled, 2, 1 and 1 tests fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…pping them (N83, R0 G12)

POST /api/capabilities silently dropped any key outside
CreateCapabilityInput. R0 sent requirementsSchema and got a 201 that
neither stored it nor said so (calls.log:371-393). A re-POST of an existing
capability ignored every new field without a word.

- The 201 now includes ignoredFields (sorted) when there are any.
  requirementsSchema adds a hint: typed parameters belong in the
  capability's CSD.
- An existing capability (200, created:false) now lists every provided key
  except id, kernelId and type, with a note that POST never updates and a
  pointer to PUT /api/capabilities/:id/availability.
- Persistence is unchanged. There is no DDL, since a new column is a parked
  schema change.

Drafted by a sonnet subagent to kits' spec, then reviewed by kits.
capabilities-create-contract.test.ts passes 4/4. With the fix reverted, 2
fail; the other 2 pin existing behaviour.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…rted firmware (N83, R0 G8)

- A 400 missing_required_fields now carries missing[] (in kernelId, deviceId,
  type, adapterType order) and a message saying deviceId is operator-chosen
  and must stay stable across re-registrations.
- A top-level firmware string is stored, trimmed and capped at 64
  characters. "unknown" is used only when it is absent. Before, it was always
  "unknown" (setup.ts:614, R0 calls.log:511).

Drafted by a sonnet subagent to kits' spec, then reviewed by kits.
setup.test.ts passes 41/41, 4 of them new. With the fix reverted, 3 of the
4 new tests fail; the 4th pins the existing default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…3, R0 G9)

The invalid_body message said "PUT raw bytes", but there is no PUT route;
POST with a binary Content-Type is the contract. The status code and error
code are unchanged.

Drafted by a sonnet subagent to kits' spec, then reviewed by kits.
cid-storage.test.ts passes 16/16. The new test fails on the old message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…compare; real tz and cron (astra pack 111 HIGH 1, HIGH 2, MEDIUM 3)

Each was reproduced first on 2cc5358:
- HIGH 1: with the real apiGate and POST /api/auth/provision, a key minted for the
  owner's email or wallet passed the owner check (200).
- HIGH 2: a Kelvin-sign principal matched k@example.com under Unicode toLowerCase (200).
- MEDIUM 3: cron 'x x x x x' and timezone 'Mars/Olympus' were stored (200).

Fixes:
- On master only a SIWE session proves identity; self-service keys can name any owner.
  A key-authenticated caller now gets 403 proven_identity_required until WP-A #326
  binds keys to proven identity.
- The owner compare is a validated EVM address compared ASCII-hex-case-insensitively,
  with no Unicode folding. An email-owned kernel can't be written here until
  verified-email identity exists.
- Timezones must exist in the runtime's IANA database, and cron is checked value by
  value.

With the fixes reverted, the 5 pack-111 tests fail.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…gnored (astra pack 111 MEDIUM 4)

Reproduced first: re-POSTing cap-x with another kernelId and type returned created:false
and ignoredFields [], although both values were discarded. They are now listed in
ignoredFields plus conflicts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
…0 (astra pack 111 MEDIUM 5)

Reproduced first: no body gave 500, because the handler destructured undefined. It now
falls back to {} and lists all four missing fields.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
@LamaSu
LamaSu marked this pull request as ready for review September 30, 2026 02:56
@LamaSu
LamaSu merged commit 139922d into master Sep 30, 2026
5 checks passed
LamaSu added a commit that referenced this pull request Oct 1, 2026
…e or padding

sameIdentity folded both sides with String(id).trim().toLowerCase(). That
lowercasing is Unicode-aware, so U+212A (KELVIN SIGN) folds to "k", and
U+0130 folds to "i" plus a combining dot. The trim also strips NBSP, BOM,
U+3000 and tab. So a principal whose id is a lookalike or a padded copy of
an owner's id matched that owner. The same class was HIGH 2 on #461 (astra
pack 111).

The compare now lowercases A-Z only and keeps every other code point and all
padding as is. A whitespace-only id still never matches. The doc comment
says not to swap to WP-A's (#326) sameIdentity while it folds with
trim().toLowerCase().

Reproduced first at dedb7f4: the new auth-actor test failed ("expected
true to be false"), and a route test PUT from a Kelvin-sign lookalike of
the template author returned 200 instead of 403. Both pass now. Two existing
tests pinned trim semantics with padded ids; they now use ASCII case
variants of the owner's id, since a padded id is a different principal.

Tests: the full gateway suite passes (189 files, 3017 passed, 6 skipped).
Gateway tsc exits 0. Mutations: 5 of 5 killed. Each of these fails a test:
restoring the old fold, a Unicode toLowerCase without trim, the ASCII fold
plus trim, no case fold at all, and dropping the blank guard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FT8WYWkfig4KNcxtMqvMuj
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