Dayward AI
Week 3 · D19About 6 hours

Cross-Service Agent Integration: Minting a User-Level JWT, JWKS Signature Verification, the inject/memory/usage Interfaces, Idempotent externalId

Connect the mini-multi-agent service to mini-koda: mint a user-level JWT, verify signatures with JWKS, and integrate the inject, memory, and usage interfaces while guaranteeing idempotency.

Today's goals 0/3

Sign in to tick these off and save your progress.

Today's Goals

  1. Implement a user-level JWT minting flow, and explain how it differs from a service-level token
  2. Implement JWKS signature verification to validate a cross-service request's legitimacy
  3. Integrate the inject/memory/usage interfaces, guaranteeing idempotency with an externalId

Yesterday's graph runs to completion, compresses history, and resumes from a checkpoint. It is still an island: every input comes from a sentence you fed in by hand. Today connects it to W2's agent platform — the first time this course's two milestone projects join up.

Plain-Language Walkthrough

Abroad on business, you present your own passport

Set out the two ends being connected. W2's mini-koda is an agent platform: it has sessions, execution records, long-term memory, and a cost ledger, and the user's data lives with it. W3's multi-agent orchestration service is another process, another repository, managed by another team, and after running a round of triage and a refund draft it needs to write its conclusion back into the user's session and read what that user said before.

So the question becomes: on what basis do two services trust each other?

The easiest answer is issuing a key. The platform generates a long random string and hands it to the orchestration service, which includes it on every request, and the platform compares and admits. That is a service-level token, and it is what most teams ship in their first version. Its problem is not insecurity, it is that once something goes wrong you have no remedy at all.

Recast it as traveling abroad and it is obvious. A service-level token is B country issuing one master key to a company in A country: whoever holds it can walk into any office and pull any file. The normal arrangement has each person enter on their own passport, with border control checking who this person is, why they came, and when the visa expires.

A user-level token is that passport. It is issued by the orchestration service and represents one specific user, carrying that user's id and what they may do this time. Three reasons make it mandatory rather than merely tidier:

First, blast radius. One service-level token leaking exposes every user's data at once, and you cannot even tell where the leak came from; one user-level token leaking loses one user's data at most, and it expires by itself in fifteen minutes.

Second, the audit cannot reach a person. In a post-mortem you can only find that the orchestration service called a write endpoint at 03:14, not on whose behalf. With a sub claim, every access log lands on a specific user.

Third, the downstream cannot make user-level permission decisions. The platform's memory endpoint must answer whose memory to return, and if identity can only be read from the request body then that question is handed to the caller — and a caller can be wrong, and can lie.

So what belongs in that passport? Far less than most people think. A JWT's payload is base64-encoded, not encrypted, and anybody holding a token can open it and read:

TextText
eyJhbGciOiJSUzI1NiIsImtpZCI6ImFnZW50LWtleS0xIn0.eyJzY29wZSI6...
  | decode the middle base64 segment, no key required
{
  "sub": "u-1",
  "iss": "http://127.0.0.1:4019",
  "aud": "mini-koda",
  "scope": "inject:write memory:read",
  "jti": "5968-2b94-2db3-4fe0",
  "iat": 1788566547,
  "exp": 1788567447
}

This course fixes those five claims (plus issued-at and expiry): sub is the user id, iss is who signed it, aud is who it was signed for, scope is what this authorization permits, and jti is the token's own number. No business data whatsoever. Two hard reasons: it cannot be hidden, so putting a phone number or a membership tier in there is publishing it in plaintext on the public internet; and whatever you put in is stale the instant it is written — the user downgrades to free ten minutes later and that token still says member, expiring only after fifteen. A token answers who you are and what you may do; what you currently are is always looked up in the database.

For a passport to work, border control has to be able to verify it. And border control does not place an international call to the issuing country to check one passport — so how?

Border control checks a published certificate, not a call home

First how not to do it: agree a shared secret on both sides, signing with it and verifying with it (symmetric signing, HS256 say). That runs with two services and has three unavoidable troubles.

Key rotation requires changing both sides simultaneously. To change the secret you have to coordinate a release with the other team, and requests in those few seconds necessarily fail verification. Anybody who has done it knows that a "two teams deploy at once" operation basically means never rotating in production.

The verifier holds signing capability. A symmetric secret means the platform can also sign any token — including one whose sub is anybody at all. Breach the platform and the attacker gains not just read access but the ability to impersonate any user.

One more caller means one more copy of the secret. Three downstream services means three shared secrets scattered across three configurations, and a leak in any one forces rotation for all.

Asymmetric signing solves all three at once: the issuer holds the private key and the verifier holds only the public one. A leaked public key does not matter — it can verify and cannot sign. To rotate, the issuer publishes the new public key, both coexist for a while, and the old one comes down once old tokens expire naturally, with nobody notified.

How does the public key reach the verifier? Through JWKS (JSON Web Key Set): the issuer publishes a public-key set at a fixed address, and this course uses /.well-known/jwks.json throughout. Anybody may fetch it, because it holds only public keys:

JSONJSON
{
  "keys": [
    {
      "kty": "RSA",
      "n": "q4sEvqo5Uu5AVgp8DzXgiW7thpERtiXDr8g1ama8n0xR02cZj7kNi3fu...",
      "e": "AQAB",
      "kid": "agent-key-1",
      "use": "sig",
      "alg": "RS256"
    }
  ]
}

kid is this key's name. It goes into the token header at signing time and the verifier picks the matching key from the set by kidthat is the entire secret of zero-downtime rotation: both public keys are published, newly signed tokens point at the new kid, old tokens at the old, and both verify.

First, how the issuer mints the token. This course fixes RS256 and a fifteen-minute lifetime:

issuer.js
import { randomUUID } from 'node:crypto'
import { SignJWT } from 'jose'
 
const ISSUER = 'http://127.0.0.1:4019'
const AUDIENCE = 'mini-koda'
const TOKEN_TTL_SECONDS = 900 // 15 minutes: enough for a cross-service call, and quick to expire
 
export async function mint(userId, scopes, privateKey) {
  const now = Math.floor(Date.now() / 1000)
  // These five claims only. No user name, no membership tier: a JWT is base64, not encryption.
  return new SignJWT({ scope: scopes.join(' ') })
    .setProtectedHeader({ alg: 'RS256', kid: 'agent-key-1' })
    .setSubject(userId)
    .setIssuer(ISSUER)
    .setAudience(AUDIENCE)
    .setJti(randomUUID())
    .setIssuedAt(now)
    .setExpirationTime(now + TOKEN_TTL_SECONDS)
    .sign(privateKey)
}

The verifying side does one thing: fetch the public key by kid, verify the signature, validate iss, aud, and exp along the way, and take the identity out of the payload.

verify.js
import { createRemoteJWKSet, jwtVerify } from 'jose'
 
// The remote key set. jose caches it and refetches on an unseen kid - so when the other
// side rotates keys, not one line of configuration changes here, let alone a restart.
const jwks = createRemoteJWKSet(new URL(`${ISSUER}/.well-known/jwks.json`))
 
export async function callerOf(token) {
  // Verify the signature and validate iss / aud / exp together. Skipping any one opens a
  // hole: without aud, a token signed for a third-party service can be pointed at you.
  const { payload } = await jwtVerify(token, jwks, {
    issuer: ISSUER,
    audience: AUDIENCE,
    algorithms: ['RS256'],
  })
  return {
    userId: String(payload.sub),
    issuer: String(payload.iss),
    scopes: String(payload.scope ?? '').split(' ').filter(Boolean),
    jti: String(payload.jti),
  }
}

The four versions share one thing worth calling out: all four libraries require you to pass issuer and audience explicitly rather than validating them by default. That is not a design flaw, it is because a library does not know what your service is called. Omitting audience is the most common security incident in cross-service integration — a token the issuer signed for a different downstream service has an equally valid signature, and not checking aud means holding the door open for somebody else's endpoint.

inject: putting an external event into this user's session

A passport states the purpose of entry, and only that purpose is permitted. Scope is that field: a token granted only inject:write is refused when used to query cost. Today opens three endpoints for three purposes.

The first is inject — the orchestration service finishes a round and injects its conclusion as a message into the user's session. The request looks like this:

JSONJSON
POST /v1/inject
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
{
  "externalId": "evt-20260905-0001",
  "text": "Checked order A1024; the shipment is held at a transfer hub, so a reship is advised."
}

The body carries no userId, and that is not an omission but a security invariant running through this whole course: identity comes only from the token, never from the body. You met its other face when D12 covered long-term memory — the memory_search tool gives the model no identity parameter, and the user id comes from the session server-side. The reason is identical: any field the caller fills in is a field that can be filled in with somebody else's value. A body is whatever the caller typed and a token is signed, and the difference in trustworthiness is an entire discipline of cryptography.

The server then does three things: find (or create) this user's session by sub, append the message to the messages table by seq (D8's table, unchanged today), and return the session id and sequence number:

JSONJSON
201 Created
{ "status": "created", "userId": "u-1", "sessionId": "s-1", "seq": 0,
  "externalId": "http://127.0.0.1:4019|u-1|evt-20260905-0001" }

Two engineering costs to think through in advance. One, injection costs money: every injected message very likely triggers a fresh execution on the platform, meaning a real model call. So this endpoint must be rate-limited, and the dimension is per user per minute rather than per caller per minute — otherwise one user's runaway retries eat everybody's quota. Two, guard against loops: the orchestration service injects a message, the platform finishes and calls the orchestration service back, which injects another... two services can pull each other into an infinite loop, and the bill is the only thing that will tell you. The simplest stanching move tags injected messages with their source, and the platform stops calling back when the source is the orchestration service.

memory: reading and writing user memory across services, and only what should be read

The second endpoint is memory, corresponding to the memories table built on D12. Before drafting a refund, the orchestration service wants to know whether this user previously said they will not accept an exchange — and that information lives on the platform.

What is most worth saying here is not how to read but why scope splits into memory:read and memory:write. Reading and writing carry entirely asymmetric risk: a bad read leaks information and a bad write pollutes data, and polluted memory keeps influencing the model's judgment in every later conversation, which is harder to notice and harder to roll back than one leak. Split into two scopes, a subagent that only needs to read memory can never obtain write capability — least privilege is not a slogan, its concrete form is how many scopes are written into this token.

At minting time: when the orchestration service mints a token for a user, it writes only the scopes this task genuinely needs. Triage needs only memory:read, and when it comes time to settle a conclusion it mints a new token with memory:write — expiring in fifteen minutes anyway, so one more minting costs nearly nothing.

There is one easily overlooked boundary: reading memory across services should return the part relevant to this task, not the user's entire memory. The platform side should support retrieval by query with a returned-row limit rather than offering a dump-everything endpoint. Once offered, some caller taking a shortcut will eventually make it the default usage, and by then the blast radius is back to service-level-token magnitude.

usage: open the billing hole on the server, not at the caller

The third is usage, reading D13's usage_ledger. The orchestration service wants to know how much this user has spent this month in order to decide whether to downgrade to a cheaper model.

D13 fixed the ledger's fields: user_id, run_id, model, kind, prompt_tokens, completion_tokens, cost_usd, created_at. The cross-service layer does one aggregation:

SQLSQL
select count(*)                              as calls,
       sum(prompt_tokens)                    as prompt_tokens,
       sum(completion_tokens)                as completion_tokens,
       sum(cost_usd)                         as cost_usd
from usage_ledger
where user_id = $1;

Note where that $1 comes from — the token's sub, not a query parameter. This is the easiest of the chapter's three endpoints to get wrong: GET /v1/usage?userId=u-2 looks so natural that a review may not look twice. Its consequence is that anybody holding any valid token can enumerate every user's spend.

Two more decisions: aggregate on the server and return only totals, no line items. Line items carry run_id and model, handing over the platform's execution detail and model-selection strategy along with them; what the caller genuinely needs is one number. And use fixed-point for money, not floating point. Today's lab sums in micro-dollar integers in memory and writes numeric with six decimals only when persisting — the reason is D13's, carried across the service boundary.

externalId: the same move, a third time

The last piece is idempotency. Cross-service calls definitely duplicate: the caller retries after a timeout, the message bus delivers at least once, the other side replays in-flight requests during a release — the same event arriving twice is inevitable, not exceptional.

You have used the countermeasure twice. D8 used the unique constraint on runs.idempotency_key to block duplicate user messages, and D13 used the same move against a cron tick triggered by two schedulers. Today is the third: give an external event an externalId, persist it into an external_events table, and let the unique constraint block duplicates.

SQLSQL
create table external_events (
  external_id text        primary key,   -- the final arbiter, same move as D8's idempotency_key
  kind        text        not null,
  payload     jsonb       not null,
  created_at  timestamptz not null default now()
);
 
insert into external_events (external_id, kind, payload)
values ($1, $2, $3::jsonb)
on conflict (external_id) do nothing
returning external_id;

The cross-service layer has one trap the previous two did not: the externalId is generated by the caller. Two different callers each inventing an evt-1 is only a matter of time, and the consequence is not an error — it is the later user silently receiving nothing, because their event was discarded as a duplicate delivery, with clean logs. So namespace it before persisting, with all three segments taken from the verified token so none can be forged:

events.js
// Who sent it (iss), on whose behalf (sub), and the event's own id: all three together
// are the idempotency key
export function namespacedExternalId(issuer, userId, externalId) {
  return `${issuer}|${userId}|${externalId}`
}
 
// created === false means this event was already handled, and the caller should receive
// the same result as the first time. The only criterion: did the insert actually insert.
export async function recordExternalEvent(store, caller, kind, externalId, payload) {
  const key = namespacedExternalId(caller.issuer, caller.userId, externalId)
  const created = await store.insertExternalEvent(key, kind, JSON.stringify(payload))
  return { created, key }
}

Two more details to fix. An externalId must be determined by the event itself, not a random value regenerated on every retry — that is no idempotency at all, as D8 established. And a duplicate delivery returns 200 rather than 409. A duplicate is not an error, it is normal in a distributed system; answering 409 makes the caller's retry logic treat it as a failure and retry harder. The right move returns 200 with the first result so the caller believes it succeeded — because it did.

Source Reading

Hands-On Lab

🧪 D19 lab: connecting mini-multi-agent to mini-koda's three endpoints

Code location: labs/agent-30days/day-19-cross-service-integration

Acceptance criteria:

  1. All nine self-checks under MOCK=1 SELFTEST=1 pnpm start pass with exit code 0 (starter as-is passes only 1, 3, and 9, with each of the other six naming an exercise point).
  2. The JWKS endpoint publishes public keys only (n, e, kid present, no private parameter d), and the minted token's claims are exactly sub, iss, aud, scope, jti, with a 900-second lifetime.
  3. A token with a tampered payload returns 401, a token that expired a minute ago returns 401, and a token granted only inject:write returns 403 when querying usage.
  4. The same user posting the same externalId twice gets duplicate on the second and only one record is stored; a different user with the same externalId must succeed.
  5. Writing userId into the body has no effect at all and the memory still lands under the token's sub; usage aggregates only its own account, totaling 0.000728 dollars.

Both services run in one process: the callee (mini-koda) listens on 3019 and the issuer on 4019, and under MOCK=1 there are zero external services — there is no model call today, and the only external dependency is the database, served by the in-memory implementation under src/infra/ where the external_events unique constraint is genuinely implemented, so idempotency really works rather than pretending to. To run against a real database, docker compose up -d in the lab root (Postgres on host port 5519), set DATABASE_URL, and run the same command with no change to business code. If you get stuck, read the self-check output — every failure names which exercise point to go and fix.

  1. Run the starter's self-check first and see which 6 of the 9 are red; that is today's entire workload.
  2. Exercise 1: complete the claims in issuer.ts and set the 15-minute lifetime, turning check 2 green.
  3. Exercises 2 and 3: replace server.ts's decodeJwt with jwtVerify and validate iss and aud, then change requireScope from letting everything through to actually judging, turning checks 4, 5, and 6 green together.
  4. Exercise 4: namespace the idempotency key in events.ts and treat the insert's return value as the sole arbiter, turning check 7 green, and observe how the behavior changes when a different user uses the same externalId.
  5. Exercise 5: change the inject and memory handlers to take identity from the token's sub only, turning check 8 green, then run the real-database version and confirm the same business code produces identical results on Postgres.

Interview Questions

Today's four questions are in the bank below, weighted toward the user-level-versus-service-level token trade-off, how JWKS works, designing and implementing idempotency keys, and the responsibility boundaries of a cross-service API. Expand a question and read the analysis before the key points — the follow-ups on question 1 (what to do when a token leaks) and question 3 (who should generate the idempotency key) are the two most likely to be pressed, so do not skip them.

Checklist and Tomorrow

  • Implement a user-level JWT minting flow, and explain how it differs from a service-level token
  • Implement JWKS signature verification to validate a cross-service request's legitimacy
  • Integrate the inject/memory/usage interfaces, guaranteeing idempotency with an externalId
  • State the three reasons for avoiding a shared secret, and why a token must carry no business data
  • Name the two places in this course where "identity comes only from the token" appeared, and the concrete consequence of violating it
  • All 5 acceptance criteria of the lab pass
  • Answer at least 3 of the 4 interview questions without looking at the key points

Tomorrow (D20) makes this system act on its own. Until today it has been passive: the user speaks and the system does one thing, and even cross-service calls have somebody else knocking first. What makes users feel this thing is useful is often the time it remembered by itself — an order stuck at a transfer hub for three days, and it asks whether you want a reship. The machinery was already assembled on D13 (central scheduling, the message bus, worker consumption), and tomorrow solves the other half: whether it should send at all. Time zones, quiet hours, a daily cap — get any one of those wrong and proactive care becomes harassment, and a user who blocks you once never comes back. Make "can be called safely" solid before discussing "going out to interrupt somebody" — the order cannot be reversed.

Interview questions

  • For service-to-service calls, would you use a service token or a user token? When does each apply?两个服务之间调用,你会用服务级令牌还是用户级令牌?分别适用于什么场景?
    Common in ChinaCommon overseasIntermediate#auth#security#api-design

    How to reason about it · think before answering

    1. The hinge is each. Answering user tokens are safer turns a design question into a slogan — the interviewer wants the conditions under which each one is correct, and the concrete cost of choosing wrong.
    2. Give the deciding question first: is there a specific user behind this call? If yes, it must be a user token. If not — fetching config, reporting metrics, running a reconciliation batch — a service token is the right answer, and stuffing in a user id would fabricate audit history.
    3. Then state the three reasons as costs, not virtues. A leaked service token means every user's data at once; a leaked user token means one user, and it expires in fifteen minutes. Audit logs with a service token only show that some service called, never on whose behalf. And a downstream service doing per-user authorization is forced to trust a userId in the request body, which the caller writes freely.
    4. Add the production view: it is rarely either-or. Real systems use the service credential to obtain user tokens — the caller proves who it is once, then mints a short-lived token representing one user. The service credential then appears only at the minting step, never on every business call.
    5. Expect: what if a token leaks? Answer in two layers — a short lifetime (fifteen minutes here) does most of the containment, and a jti denylist is the supplement. Do not lead with a denylist: it puts a database lookup in front of every verification and gives away the whole point of stateless verification.
    6. Expect: how fine-grained should scopes be? Offer a usable rule — split along asymmetric risk. A bad read leaks information; a bad write poisons data that keeps influencing every later turn. So read and write always split; finer than that only if a real caller genuinely needs just one half.

    分析过程 · 先想清楚再作答

    1. 题眼在「分别」。答「用户级更安全」就把一道设计题做成了口号题——面试官想看你能不能说出两者各自成立的条件,以及选错的具体代价。
    2. 先给判断依据,一句话就能拆开:这次调用**有没有一个具体的用户在背后**。有,就必须是用户级;没有(拉配置、上报指标、跑对账批处理),服务级才是对的,硬塞一个用户 id 进去反而是伪造审计记录。
    3. 然后把用户级的三条理由说成代价而不是优点:服务级令牌泄露一次等于全量用户数据泄露,用户级泄露一张只丢一个用户且十五分钟自动作废;服务级在审计日志里只能查到「某服务调了一次」,查不到替谁操作;下游做用户级权限判断时,服务级令牌逼着它去信请求体里的 userId,而那是调用方可以随便写的。
    4. 补一句生产视角:两者不是二选一,真实系统里常常是「服务级令牌用来换用户级令牌」——调用方先用自己的服务凭证证明自己是谁,再申请一张代表某个用户的短期令牌。这样服务凭证只出现在铸造这一步,不出现在每一次业务调用里。
    5. 可以预期的追问一:令牌泄露了怎么办?答案要分两层——短有效期(本课 15 分钟)是止损的主力,撤销列表按 jti 拉黑是补充;不要上来就说「用黑名单」,那等于给每次验签加一次数据库查询,把无状态验签的好处全赔进去了。
    6. 可以预期的追问二:那 scope 该切多细?给一条可操作的判据——按「读写不对称的风险」切,读错了泄露信息、写错了污染数据且会持续影响后续每一轮对话,所以 read 和 write 必须分开;再细就要看有没有真实的调用方只需要其中一半。

    Key points

    • The deciding question is whether a specific user stands behind the call: yes means user token, no (config, metrics, reconciliation) means service token
    • A leaked service token exposes every user; a leaked user token exposes one and expires on its own
    • Auditing has to reach a person — only the sub claim answers who the call was made on behalf of
    • With a service token the downstream must trust a userId in the request body, which the caller can forge
    • Common production shape: the service credential only buys short-lived per-user tokens and never appears on business calls
    • After a leak, short lifetimes do the containment and a jti denylist supplements it — do not trade away stateless verification by default

    答题要点

    • 判断依据是「这次调用背后有没有一个具体用户」:有就用用户级,没有(配置、指标、对账批处理)才用服务级
    • 服务级令牌泄露的爆炸半径是全量用户,用户级只影响一个用户且短期自动失效
    • 审计要能落到人:只有 sub 字段能回答「当时是替谁操作的」
    • 下游要做用户级权限判断时,服务级令牌逼着它去信请求体里的 userId,而那是调用方可以伪造的
    • 生产里常见组合:服务凭证只用来换取代表某个用户的短期令牌,不出现在每次业务调用里
    • 泄露后的止损顺序是短有效期优先、jti 撤销列表补充,别一上来就上黑名单换掉无状态验签
  • How does JWKS-based verification work, and why does it fit cross-service scenarios better than a shared secret?JWKS 验签是怎么工作的?为什么跨服务场景下它比共享密钥更合适?
    Common in ChinaCommon overseasBasic#auth#jwt#security

    How to reason about it · think before answering

    1. This is the giveaway question of the chapter, but it still separates people: can you turn key rotation into a concrete operational sequence rather than saying it is easier to manage?
    2. Describe the mechanism in three sentences. The issuer holds the private key and signs; the public key set is published at a fixed address (/.well-known/jwks.json here); the token header carries a kid, and the verifier picks the matching public key from the set. Verification needs only public material, so the endpoint is public by design.
    3. Then give three reasons, each as an operational action: rotation needs no synchronized deploy on both sides (publish the new public key, let both coexist, drop the old one after old tokens expire); the verifier holds verification power, not signing power, so compromising it does not let anyone forge tokens; and adding a caller does not scatter another copy of a secret.
    4. Volunteer the part people forget: verifying the signature is not the whole check. A valid signature only proves the issuer signed it. You still validate iss, aud and exp — and missing aud is the most common cross-service incident, because a token the issuer signed for a different downstream is equally well signed, so skipping audience means holding the door open for someone else's API.
    5. Two engineering details worth adding: cache the key set but refetch on an unknown kid, or rotation day becomes a mass failure; and allow a small clock skew on exp, but not so large that it cancels out the point of short lifetimes.
    6. Expect: so is HS256 unusable? Answer that it is fine when one service signs and verifies its own tokens, and it is faster. The criterion is whether signer and verifier sit in the same trust domain; across domains, asymmetric is mandatory. Framing it as a trade-off shows judgment rather than memorization.

    分析过程 · 先想清楚再作答

    1. 这是本章的送分题,但送分题也有区分度:能不能把「密钥轮换」这件事讲成一个具体的运维动作,而不是一句「更方便管理」。
    2. 先讲机制,三句话:签发方持私钥签名,公钥集合挂在一个固定地址上(本课用 /.well-known/jwks.json);令牌头部带一个 kid,验签方按 kid 从集合里挑对应的公钥;验签只用公钥,所以这个地址是公开的,谁都能拉。
    3. 再讲为什么比共享密钥好,三条都要落到运维动作上:轮换不用两边同时发版(新旧两把公钥并存一段时间,等老令牌自然过期再摘旧的);验签方拿到的只是验签能力而不是签名能力,被入侵也伪造不出令牌;多一个调用方不用多散一份密钥出去。
    4. 然后主动补上最容易被忽略的一段:验签不等于验完。签名合法只说明「这确实是那个签发方签的」,还必须校验 iss、aud、exp——**漏掉 aud 是跨服务集成里最常见的事故**,因为签发方给别的下游服务签的令牌,签名一样合法,不校验受众就等于替别人的接口开门。
    5. 工程细节可以再加两条:公钥集合要缓存,但遇到没见过的 kid 要能主动重拉,否则轮换那一刻会集体失败;以及时钟偏移,exp 校验要留一点容忍度,但容忍度不能大到把短有效期的意义抵消掉。
    6. 可以预期的追问:那 HS256 是不是就不能用了?答「同一个服务自己签自己验时它没问题,而且更快」——判据是签名方和验签方是不是同一个信任域,跨了域就必须非对称。这么答显得你在做权衡而不是背结论。

    Key points

    • Mechanism: private key signs, public key set sits at a fixed URL, the token header carries a kid, the verifier selects by kid
    • Rotation needs no synchronized deploy: publish the new key, let both coexist, retire the old one after old tokens expire
    • The verifier gets verification power only, never signing power, so compromising it cannot forge tokens
    • Adding callers does not scatter more secrets; the public key being public is the design intent
    • Beyond the signature you must check iss, aud and exp — skipping aud opens your API to tokens signed for someone else
    • Cache the key set but refetch on an unknown kid; HS256 is still reasonable when one service signs and verifies its own tokens

    答题要点

    • 机制:私钥签名、公钥集合挂在固定地址、令牌头部带 kid、验签方按 kid 取公钥
    • 轮换不用两边同时发版:新旧公钥并存,等老令牌自然过期再摘旧的
    • 验签方只拿到验签能力而不是签名能力,被入侵也伪造不出令牌
    • 调用方增加不需要多散一份密钥,公钥公开本来就是设计意图
    • 验签之外必须校验 iss、aud、exp,漏掉 aud 等于替别的下游服务开门
    • 缓存公钥集合但要能按未知 kid 主动重拉;HS256 在同一信任域内自签自验仍然是合理选择
  • How do you design an idempotency key for cross-service calls — who generates it, where does it live, and what do you return on a repeat?跨服务调用的幂等键该怎么设计?由谁生成、存在哪、重复了返回什么?
    Common in ChinaCommon overseasIntermediate#idempotency#distributed-systems#api-design

    How to reason about it · think before answering

    1. This question separates people entirely on implementation detail. Anyone can define idempotency; answering who generates the key, where it lives, and what a repeat returns shows whether you have actually built one.
    2. Start with the rule: the final arbiter must be a database uniqueness constraint, not an application-level check-then-insert. Check-then-insert always passes single-process tests and produces duplicates the moment you run two replicas — both check, both find nothing, both insert. The window is too narrow to reproduce under load testing and wide enough to produce dirty rows daily in production.
    3. Who generates it: the caller, because only the caller knows that two retries are the same event. But the key must be derived from the event itself, never a fresh random UUID per retry — that is idempotency in name only. Same criterion as the user-message case from day 8.
    4. Cross-service adds one trap worth the most points: never use the caller's raw id as the key. Two different callers will eventually both produce evt-1, and the failure is not an error — the second user silently receives nothing, because their event is treated as a duplicate and the logs look clean. Namespace it: issuer plus user id plus event id, all three taken from the verified token so none of them can be forged.
    5. What to return also matters: a repeat gets 200 with the original result, not 409. Repeats are normal in distributed systems; a 409 makes the caller's retry logic treat it as a failure and the situation compounds.
    6. Expect: does this table grow forever? Yes, so give it a retention window — a TTL matching the replay window the business tolerates, say seven days, with periodic cleanup. Say plainly that a duplicate arriving after cleanup is treated as new; that is a stated trade-off, not a hole.

    分析过程 · 先想清楚再作答

    1. 这题的区分度全在实现细节上。概念谁都会说,能不能答对「谁生成、存在哪、返回什么」这三个具体问题,直接暴露你有没有真做过。
    2. 先立一条铁律:**幂等的最终裁判必须是数据库的唯一约束**,不是应用层的「先查一下有没有」。先查后插在单进程测试里永远是对的,一上多实例就出双份——两个副本同时查、同时发现没有、同时插入,这个时间窗压测时窄到复现不出来,上线后每天出几条脏数据。
    3. 再答「谁生成」:由**调用方**生成,因为只有它知道重试的那两次是同一件事;但键必须由事件内容决定,不能是每次重试重新生成的随机 UUID——那等于没有幂等。这条和 D8 的用户消息幂等是同一条判据。
    4. 跨服务比同服务多一个坑,这是本题最有价值的一点:**调用方给的 id 不能直接当键用**。两个不同的调用方各自造出 evt-1 是迟早的事,撞车之后的表现不是报错,而是后来那个用户静默收不到消息——他的事件被当成重复丢掉了,日志里干干净净。所以落库前要加命名空间,用「签发方 + 用户 id + 事件 id」三段拼,而且三段都取自验签后的令牌,伪造不了。
    5. 「返回什么」也是个坑:重复送达要返回 200 并附上第一次的结果,不要返回 409。重复不是错误,是分布式系统的常态;回 409 会让调用方的重试逻辑把它当失败处理,越重试越乱。
    6. 可以预期的追问:这张表会不会无限涨?答「会,所以要有保留期」——按业务能接受的重放窗口设一个 TTL(比如 7 天)定期清理,同时说明清理之后超期的重复请求会被当成新事件,这是一个明确的、可接受的取舍,不是漏洞。

    Key points

    • The arbiter is a unique constraint plus on conflict do nothing; check-then-insert duplicates as soon as you run two replicas
    • The caller generates the key, but it must be derived from the event — a fresh UUID per retry is not idempotency
    • Never use the caller's raw id: namespace it with issuer plus user id plus event id, all taken from the verified token
    • A collision does not raise an error; it silently drops another user's event and leaves clean logs
    • Return 200 with the original result on a repeat, never 409, or the caller's retry logic treats success as failure
    • Give the table a retention window and state that post-cleanup repeats count as new events — a stated trade-off, not a hole

    答题要点

    • 最终裁判是数据库唯一约束加 on conflict do nothing,先查后插在多实例下必然出双份
    • 键由调用方生成,但必须由事件内容决定,随机 UUID 等于没有幂等
    • 调用方给的 id 不能直接当键:加命名空间(签发方 + 用户 id + 事件 id),三段都取自验签后的令牌
    • 撞车的后果不是报错而是另一个用户静默收不到消息,日志里看不出异常
    • 重复送达返回 200 加第一次的结果,不要返回 409,否则调用方会当失败继续重试
    • 幂等表要设保留期,超期后的重复会被当成新事件,这是明确取舍不是漏洞
  • You are designing the API surface an Agent platform exposes to other services. How do you draw the responsibility boundaries?设计一组给外部服务调用的 Agent 平台接口,你会怎么划分职责边界?
    Common in ChinaCommon overseasDeep dive#api-design#security#architecture

    How to reason about it · think before answering

    1. This is an open design question testing whether you have a reusable criterion. Candidates who start listing endpoints run out of material under follow-ups; candidates who give the criterion first turn follow-ups into extra points.
    2. Offer the criterion: draw boundaries by who owns the data, not by who calls it. Sessions, run records, memories and the cost ledger belong to the platform, so the platform exposes exactly three things — write one event in (inject), read and write memory, and read usage. Orchestration belongs to the caller, so the platform should not offer run this graph for me; that pulls someone else's responsibility inside your walls and freezes both sides.
    3. Second criterion, the security invariant that runs through the whole course: identity comes from the token, never from the request body. No endpoint accepts a userId; the server always reads sub. Break this once and the authorization model collapses — a usage endpoint that accepts a userId query parameter lets any valid token enumerate everyone's spend. The same principle appeared on the memory search tool: the model gets no identity parameter, the server fills it in.
    4. Third, return the minimum necessary. Usage returns aggregates, not line items, because line items carry run ids and model choices — that hands over your internal strategy. Memory supports a query with a result limit rather than dump everything this user ever said; once that exists, some caller in a hurry will make it the default.
    5. Fourth, every write endpoint must be safely replayable: an externalId, a uniqueness constraint underneath, and 200 on a repeat. Cross-service calls will be duplicated; this is not optional.
    6. Expect: what dimension do you rate-limit on? Per user, not per caller — limiting per caller lets one user's runaway retries consume everyone's budget. Also guard against loops: tag injected messages with their source, or two services can pull each other into an infinite cycle and the bill is the only thing that tells you.

    分析过程 · 先想清楚再作答

    1. 这是开放题,考的是你有没有一条能反复用的划分依据。上来就罗列接口清单的人会被追问到没词;先给依据再给清单的人,追问反而是加分机会。
    2. 给一条判据:**按「谁拥有这份数据」划,不按「谁调用它」划。** 会话、执行记录、记忆、成本台账都属于平台,所以平台开的三个口子恰好是「写一条进来(inject)」「读写记忆(memory)」「查账(usage)」;编排逻辑属于对方,平台就不该提供「帮我跑一遍这个图」的接口——那是把对方的职责搬到自己身上,将来两边都改不动。
    3. 第二条判据是**贯穿全课的安全不变量:身份只能来自令牌,不能来自请求体**。所有接口都不接受 userId 参数,服务端一律从令牌的 sub 取。这条一旦破例,权限模型就整个塌了:查成本的接口如果接受 userId 查询参数,任何一张有效令牌都能遍历所有人的消费金额。同一条原则在 D12 的记忆检索工具上也出现过——不给模型身份参数,服务端自己填。
    4. 第三条是**返回粒度要按最小必要给**。usage 只返回汇总不返回明细,因为明细里带着执行 id 和模型选型,等于把平台的内部策略一并交出去;memory 要支持按 query 检索并限制条数,不提供「把这个人的所有记忆倒出来」的接口——一旦提供,它迟早会被某个图省事的调用方用成默认写法。
    5. 第四条是**每个写接口都要能被安全重放**:带 externalId、唯一约束兜底、重复返回 200。跨服务调用一定会重复,这不是要不要做的问题。
    6. 可以预期的追问:那限流按什么维度做?答「每用户,不是每调用方」——按调用方限流的话,一个用户的异常重试会把所有人的额度吃光;另外写接口要防回环,注入的消息要打来源标记,否则两个服务能把彼此拉进无限循环,账单是唯一会提醒你的东西。

    Key points

    • Draw boundaries by data ownership, not by caller: sessions, memory and the ledger belong to the platform, orchestration belongs to the caller
    • Three endpoints for three kinds of ownership — inject, memory, usage — and no run this graph for me endpoint that crosses the line
    • No endpoint accepts a userId; identity always comes from the token's sub, and one exception collapses the model
    • Return the minimum necessary: usage gives aggregates only, memory takes a query with a limit instead of dumping everything
    • Every write endpoint carries an externalId backed by a uniqueness constraint and answers 200 on repeats
    • Rate-limit per user rather than per caller, and tag injected messages with their source so two services cannot loop forever

    答题要点

    • 按「谁拥有这份数据」划边界,不按「谁调用」划:会话、记忆、台账属于平台,编排属于对方
    • 三个口子对应三种所有权:inject 写入、memory 读写、usage 查账;不提供「帮我跑图」这种越界接口
    • 所有接口都不接受 userId 参数,身份一律从令牌 sub 取——这条破例一次权限模型就塌了
    • 返回粒度按最小必要:usage 只给汇总不给明细,memory 按 query 限条数而不是全量倒出
    • 每个写接口都带 externalId 并由唯一约束兜底,重复返回 200
    • 限流按每用户而不是每调用方;注入的消息要打来源标记防止两个服务互相回环

Comments