Repository navigation
feat(call-state-reconciler): read what actually happened to a call - #337
Conversation
|
Must Fix:\n\n- Require authentication/authorization on the remotely reachable call, call-result/transcript, and webhook-inbox endpoints.\n- Mask destinations and deeply sanitize every returned, stored, and displayed provider/result field.\n- Pin credential-bearing traffic to an approved HTTPS origin instead of accepting an arbitrary environment URL.\n- Remove plausible real contact and real-call artifacts from the current tree, linked public package, and affected public history.\n\nI have intentionally not repeated any sensitive value here. |
d9d70d4 to
4742c09
Compare
|
All four done. The first one was the real thing and you were right to lead with The read endpoint
It is not fixed with a password on the endpoint. A read now has to present a POST is still open, and I think that is right for a demo whose whole job is that The webhook inbox is unchanged in shape. The id is 32 hex characters, the page SanitisingThe response is an allowlisted projection now, never the payload. Fields are Transcript turns cross as a count and never as words, which is the useful half Allowlist rather than a denylist for the obvious reason. A denylist only knows The base URLEvery request from that client carries a bearer token, so whoever sets the base Real numbers, and the one thing I would push back onOut of the tree. The built-in list is the 555-0100 range only, and an operator I ticked the "clearly fictional" box in the checklist when the tree contained Where I would push back gently is the timestamps in Happy to take them out if you still want them gone. I would rather ask than Where it standsThe engine ships as The same fixes are live on the deployment, not just in the branch. |
|
Must Fix before merge:
|
4742c09 to
5c4adec
Compare
|
Thanks for the second pass. The duplicate call, which was realThe intent key was the one item here I had genuinely got wrong, and it was not The awkward part is that the skill in this same PR says not to do that, in What it does now: The browser names the press. That name is the authorization, because pressing The server writes the intent down before the request leaves, keyed on Nothing outside Only an outright refusal from the API (400, 401, 403, 404, 422) is treated as Verified on the live deployment, same key twice: One call id, one budget unit spent, no second dial. The client now retries a Eight tests cover the store and the key, including that the key contains no The other fiveThese read to me like the same scan against the same head, and that head already Where it stands today: The live-call endpoint issues a read token when it places a call, an HMAC over The response is an allowlisted projection built field by field. Transcript turns The client defaults to The built-in destination list is the 555-0100 fiction range only. Real numbers On re-fetching rather than trusting the caller: the live route already re-reads HistoryDone, not just the tree. I audited what was actually in there first: the two History is rewritten and the numbers are gone from all 32 commits and from the The timestamps in |
5c4adec to
928a57f
Compare
|
Must Fix before merge:
|
A finished call hands back a status word, a task_completed boolean and a confidence score. task_completed is the one everything branches on, and it comes back true for a voicemail box: the recording answers, the agent asks its question into the tone, and the call ends completed with a schema-valid result. Nothing in that payload is wrong. There is no field in it that says a person was on the line. Adds skills/call-state-reconciler, which reads a call as three separate answers, how it ended, whether the job got done, and whether the answer came from anybody speaking, each carrying whether it was quoted from a field, inferred from other fields, or absent because nothing carries it. Adds apps/web/asheard, a runnable console over the same engine, which ships as asheard on npm and is published from CI with provenance. Paste a payload, read a batch worst-first, or see which endings each surface can actually express. All of that runs on fixtures with no credentials. The one route that can dial reads back only a call it placed, checked by a token it issued, and answers with an allowlisted projection rather than the payload. Transcript turns cross as a count, never as words, and anything phone-shaped is masked wherever it appears. The client sends a bearer token to the API origin and nowhere else unless a caller writes that down explicitly. No dialable number ships in the tree; the built-in destination list is the range reserved for fiction, and an operator adds their own through the environment. The skill's references cover what was measured against the live API and how to repeat it, including the attempt timestamps being rewritten shortly after a call settles, which makes duration depend on when you looked and rounds a sub-second attempt to zero. 111 tests in the app, no network and no credentials, run against the package's own fixtures. Repository validation passes.
928a57f to
938958e
Compare
|
Thanks for the third pass. All three are in at
You're right that a key the browser mints isn't authentication. The browser-side id is still there, but it only handles idempotency now, so a retry finds the call that already exists. Dialling needs an operator token. It's an HMAC over the exact E.164 being dialled, made from
It doesn't store or serve the posted body anymore. At all. A delivery gets read once, for a call id. Then the server fetches that call from CALL-E with its own key, and only that fetch, projected and masked, lands in the inbox. An id that isn't on the account gets a one-line note and nothing else. Inbox addresses are CALL-E deliveries still aren't signed, so I can't authenticate the sender itself. What I can do is make sure nothing the sender says gets believed, and that's the design now.
Every JSON response from
The linked repo got the same treatment: fixtures, tests, comments, README and DECISIONS. Then I squashed its history to one commit and removed the old tags, so no earlier tree is reachable from it. Two things I can't reach, so I'll just say them. Older npm versions (0.1.1 to 0.2.1) are past the unpublish window. I can deprecate them if you want. And earlier heads of this PR stay in this repo's Rebased on main, one commit, |
|
Superseding review note: the current head closes the server-verifiable destination, webhook/refetch, and deep-masking items.\n\nRemaining Must Fix:\n- Purge the linked real-call measurement corpus and contact/provider artifacts from the linked project and prior public PR history, then rewrite the affected history. The current tree is improved, but those public objects remain in scope. |
|
Superseding review under the community policy adopted on 2026-09-11: The former blanket requirements for synthetic-data/history-cache purging and production-level external certification are withdrawn where applicable. Previously closed basic-safety items remain closed; no actual sensitive-data disclosure was established by those residual items. The linked public source and reachable history were refreshed; the reviewed examples are synthetic or controlled scripted test scenarios. Proportionate verification and clean-source repository validation passed. No current Must Fix remains. |
Summary
A call to a voicemail box comes back
completed, withtask_completed: true, ahigh confidence score, and a schema-valid result. Nothing in that payload is
wrong. There is just no field in it saying a person was ever on the line, and
task_completedis the field almost everything branches on.This adds a skill and an app for reading a call properly: how it ended, whether
the job got done, and whether the answer came from anybody speaking. Three
questions, answered separately, each one carrying the field it came from and
whether that was quoted, inferred, or absent because nothing carries it.
Absent is the one that does the work. A reading that cannot say "no field here
carries that" will invent it instead.
The one field that changes everything
The calls guide already says the API returns no AMD disposition and no
answered_byfield, and tells you to define the classification yourself in aper-recipient schema. That is the fix, and it is one field.
So when the skill cannot tell you who picked up, the first thing it does is name
the schema that would tell you. A check that only ever says no gets switched off
in a week.
skills/call-state-reconciler/Reads the call task, its attempts and its event stream together, and flags four
states that come up in issues here: a call nobody is watching any more, a
returned call older than the request that returned it, a clock that contradicts
itself, and a retry that is not safe to send.
It reads fields and nothing else. No transcript interpretation, no scoring, no
model, so every sentence traces to a value and says the same thing twice.
references/measurements.mdcovers what was checked against the live API andhow to repeat it. Anything seen once is written down as seen once.
apps/web/asheard/A console over the same engine. Paste a payload and read it, look at a batch in
the order somebody should deal with it, or see which endings each surface can
actually express, generated from the mappers rather than typed by hand.
All of that runs on fixtures. No key, no network, nothing stored.
One thing worth arguing about
The mapper reads an ending from the attempt-level
failure_code, and the errorsguide says not to branch on that field. I think the guide is right and this is
still worth doing, but you should decide rather than discover it in review.
The reading is always marked as inference, never as stated. It never resolves
the business outcome, never retries anything, and always routes to a person.
Only codes actually observed are decoded at all; anything else stays unknown
rather than being read off SIP numbering.
The reason it is marked inference is not caution for its own sake. Two
destinations that could not be reached came back with two different codes, so
whatever that field is, it is not a stable map onto an ending.
Happy to put it behind a flag that defaults to off if you would rather.
Type
Side effects
One page can place calls and nothing else can.
/livedials three fixed destinations: the two published speaking clocks, whichanswer instantly and hang up on themselves, and a number in the 555-0100 range,
which is held back for fiction and belongs to nobody. The list is not accepted
from the request, the check runs on the resolved number rather than the id it
arrived under, and the key is read from the server environment so the button
cannot be pointed at another account.
A daily and per-visitor budget is spent before dialling, not after, and it fails
closed. If the counter cannot be read, no call goes out. It is not refunded when
a call fails, because a failed attempt still costs the account and a limiter that
refunds errors can be beaten by causing them.
Without
CALLE_API_KEYset, that page answers 503 and dials nothing. That isthe default for anyone who clones this.
Nothing here schedules anything or creates a recurring job, so there is nothing
to cancel.
Validation
npm testinapps/web/asheard, 111 tests, no network and no credentials.The suite runs against the package's own fixtures, so it exercises what the
app imports rather than a copy of it.
npm run buildandtsc --noEmitclean from a fresh install.python scripts/validate_repository.pypasses.national time service.
The engine ships as
asheardon npm,published from CI with provenance, so the app depends on a released package
rather than carrying a copy of it.
Checklist
docs/git-naming-conventions.md.python scripts/validate_repository.pypasses.