Skip to content

feat(call-state-reconciler): read what actually happened to a call - #337

Merged
Ray-56 merged 1 commit into
CALLE-AI:mainfrom
cnpierrepapi:feat/call-state-reconciler
Sep 11, 2026
Merged

Ray-56 merged 1 commit into
CALLE-AI:mainfrom
cnpierrepapi:feat/call-state-reconciler

Conversation

@cnpierrepapi

Copy link
Copy Markdown
Contributor

Summary

A call to a voicemail box comes back completed, with task_completed: true, a
high 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_completed is 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_by field, and tells you to define the classification yourself in a
per-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.md covers what was checked against the live API and
how 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 errors
guide 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

  • New skill
  • New runnable app
  • New workflow plugin
  • New provider adapter
  • New scheduler recipe
  • README awesome-list entry
  • Safety or documentation update
  • Validation or tooling update

Side effects

One page can place calls and nothing else can.

/live dials three fixed destinations: the two published speaking clocks, which
answer 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_KEY set, that page answers 503 and dials nothing. That is
the default for anyone who clones this.

Nothing here schedules anything or creates a recurring job, so there is nothing
to cancel.

Validation

  • npm test in apps/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 build and tsc --noEmit clean from a fresh install.
  • python scripts/validate_repository.py passes.
  • Every phone number in the diff is either in the fiction range or a published
    national time service.

The engine ships as asheard on npm,
published from CI with provenance, so the app depends on a released package
rather than carrying a copy of it.

Checklist

  • Repository-facing content is written in English.
  • Branch name, commit messages, and PR title follow docs/git-naming-conventions.md.
  • No secrets, tokens, private phone numbers, call recordings, or private transcripts are included.
  • Real-world side effects are clearly described.
  • Phone numbers are masked in documentation and test fixtures unless they are clearly fictional.
  • Recurring workflows include cancellation behavior.
  • Runnable code has a dry-run, fake-server, or no-call path by default.
  • python scripts/validate_repository.py passes.

@Ray-56

Ray-56 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

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.

@cnpierrepapi
cnpierrepapi force-pushed the feat/call-state-reconciler branch from d9d70d4 to 4742c09 Compare September 7, 2026 10:54
@cnpierrepapi

Copy link
Copy Markdown
Contributor Author

All four done. The first one was the real thing and you were right to lead with
it.

The read endpoint

GET /api/live took any call id, fetched it with the server's own key, and
returned the raw payload. No authentication. Anyone who knew or guessed an id
could read any call on that account, transcripts included. I built an
unauthenticated window onto the whole account and did not see it.

It is not fixed with a password on the endpoint. A read now has to present a
token that only the route which placed the call could have produced, so an id
nobody was handed cannot be read at all. Enumeration is not slower, it is
impossible. The token is an HMAC over the call id, so there is no store to keep
in sync and it survives a cold start.

POST is still open, and I think that is right for a demo whose whole job is that
you can press the button. It is capped daily and per visitor, spent before
dialling rather than after, and it fails closed: if the counter cannot be read,
nothing is dialled.

The webhook inbox is unchanged in shape. The id is 32 hex characters, the page
says to treat the URL as the secret because it is, and the store expires in 24
hours. If you want that behind a token too, say so and it is the same change.

Sanitising

The response is an allowlisted projection now, never the payload. Fields are
copied in by name, one at a time.

Transcript turns cross as a count and never as words, which is the useful half
without the sensitive half. Anything phone-shaped is masked wherever it turns
up, including inside a structured result, because a result is written by
whoever wrote the task and can quote a number straight back at you.

Allowlist rather than a denylist for the obvious reason. A denylist only knows
about the fields somebody already thought of, and providers add fields.

The base URL

Every request from that client carries a bearer token, so whoever sets the base
URL decides where the key ends up. It defaults to the API origin now, refuses
plaintext unless the host is loopback, and anything else has to be written down
by the caller as an explicit option. A local fake server still works, which was
the only real reason the field existed.

Real numbers, and the one thing I would push back on

Out of the tree. The built-in list is the 555-0100 range only, and an operator
supplies anything else through the environment on their own deployment. A
repository that ships a working phone number is asking a reviewer to take it on
trust, and you have no way to tell a national time service from somebody's
landline by looking.

I ticked the "clearly fictional" box in the checklist when the tree contained
two real numbers. That was wrong of me and it is the part of this I am least
comfortable with.

Where I would push back gently is the timestamps in
skills/call-state-reconciler/references/measurements.md. They came from calls
to the US speaking clock, a published national time service that answers a
recording and hangs up on itself. No person is on either end and there is no
contact data in them. They are also the evidence for the thing the document is
about, so removing them leaves an assertion where a measurement used to be.

Happy to take them out if you still want them gone. I would rather ask than
quietly decide that one for you.

Where it stands

The engine ships as [email protected], published from CI with provenance, and the
app depends on the released package rather than carrying a copy. 113 tests in
the library, 111 in the app, no network and no credentials in either.
scripts/validate_repository.py passes. The diff has no dialable number left in
it.

The same fixes are live on the deployment, not just in the branch.

@Ray-56

Ray-56 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Must Fix before merge:

  • Add real authentication and exact authorized-destination binding to the remotely reachable live-call endpoint.
  • Authenticate the webhook/status/transcript surfaces and re-fetch trusted results instead of accepting caller-supplied data.
  • Mask and deeply sanitize destinations and provider output before returning or persisting it.
  • Pin bearer credentials to the approved HTTPS CALL-E origin.
  • Remove real-call/transcript/identifier artifacts from the linked public project’s current tree and relevant history.
  • Use a stable intent key and halt/reconcile an ambiguous create instead of allowing a retry to create a duplicate call.

@cnpierrepapi
cnpierrepapi force-pushed the feat/call-state-reconciler branch from 4742c09 to 5c4adec Compare September 8, 2026 18:20
@cnpierrepapi

Copy link
Copy Markdown
Contributor Author

Thanks for the second pass.

The duplicate call, which was real

The intent key was the one item here I had genuinely got wrong, and it was not
a small thing. The route built its idempotency key out of the destination, the
question and Date.now(). That is a key derived from the attempt, so every
retry of one button press looked like a fresh call and the phone could ring
twice.

The awkward part is that the skill in this same PR says not to do that, in
SKILL.md under "the retry is not safe", and ledger/ exists to make it easy.
The demo was the last place in the tree still doing it the old way.

What it does now:

The browser names the press. That name is the authorization, because pressing
the button is what authorized the call, and it stays fixed across every retry of
that press.

The server writes the intent down before the request leaves, keyed on
deriveIdempotencyKey(authorization, channel) from the package. Reservation is
a single SET NX, so two requests racing the same key resolve to one record
rather than two.

Nothing outside reserved is ever dialled. A bound intent hands back the call
that already exists. A record sitting at submission_unknown is refused with a
409 and a sentence saying the call may or may not have gone out, and there is no
edge back to reserved, because once a request may have been accepted there is
no honest way to pretend it was not.

Only an outright refusal from the API (400, 401, 403, 404, 422) is treated as
"nothing was created". Everything else, including 409 and any 5xx, is ambiguous
and halts. Guessing wrong in that direction costs the demo a lane. Guessing
wrong in the other direction costs somebody a second phone call.

Verified on the live deployment, same key twice:

POST  -> 200  {"callId":"call_EJrO2ugj1b5GisZDvpousA", ... ,"remainingToday":39}
POST  -> 200  {"callId":"call_EJrO2ugj1b5GisZDvpousA", ... ,"replayed":true}

One call id, one budget unit spent, no second dial. The client now retries a
lost response once, which it could not safely do before.

Eight tests cover the store and the key, including that the key contains no
timestamp and that submission_unknown cannot go back. 119 tests in the app,
scripts/validate_repository.py passes.

The other five

These read to me like the same scan against the same head, and that head already
carries the fixes from your first round. If something in there still fails,
point me at the file and I will fix it rather than argue.

Where it stands today:

The live-call endpoint issues a read token when it places a call, an HMAC over
the call id. A read without it is a 403, so an id nobody was handed cannot be
read at all. Destinations are checked on the resolved number, not the id.

The response is an allowlisted projection built field by field. Transcript turns
cross as a count and never as words. Anything phone-shaped is masked wherever it
appears, including inside a structured result.

The client defaults to https://api.heycall-e.com, refuses plaintext unless the
host is loopback, and anything else has to be written down by the caller as an
explicit option.

The built-in destination list is the 555-0100 fiction range only. Real numbers
live in the environment on our own deployment.

On re-fetching rather than trusting the caller: the live route already re-reads
every call from the API with the server key and never renders anything the
browser sent. The webhook inbox is the one place caller-supplied data is shown,
and that is what it is for, a URL you can paste into something that already
sends webhooks with no key to hand over. Everything arriving there is stamped
unsigned before it is displayed. If you want it to go further and re-fetch by
call id when the payload names one on our account, say so and I will add it.

History

Done, not just the tree. I audited what was actually in there first: the two
speaking-clock numbers in src/reconciler/destinations.ts, across four commits.
No transcripts, no call payloads, no ids. artifacts/ was gitignored from the
first commit and never tracked.

History is rewritten and the numbers are gone from all 32 commits and from the
release tags, verified by scanning every commit in a fresh clone rather than
trusting the rewrite. One thing a rewrite cannot reach: the tarballs for
asheard 0.1.x and 0.2.0 on npm still carry them, since a published tarball is
immutable. Say the word and I will deprecate those versions.

The timestamps in references/measurements.md are still there and still yours
to call.

@cnpierrepapi
cnpierrepapi force-pushed the feat/call-state-reconciler branch from 5c4adec to 928a57f Compare September 8, 2026 18:33
@Ray-56

Ray-56 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Must Fix before merge:

  • Require server-verifiable operator authorization bound to the exact live destination; a caller-minted intent token is not authentication.
  • Authenticate the webhook/inbox surface, re-fetch provider state, and never store or serve the caller payload as trusted call evidence.
  • Deeply mask every live response and replace the published real-call measurement corpus in the current and linked public history with synthetic evidence.

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.
@cnpierrepapi
cnpierrepapi force-pushed the feat/call-state-reconciler branch from 928a57f to 938958e Compare September 10, 2026 18:10
@cnpierrepapi

Copy link
Copy Markdown
Contributor Author

Thanks for the third pass. All three are in at 938958e, and I went a bit further than patching this time.

  1. Operator authorization

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 ASHEARD_OPERATOR_SECRET with npm run operator-token -- dial <number>. The route checks it against the resolved destination, after the allowlist and before anything gets reserved or spent. A token for one number won't dial another. A random string doesn't verify. No secret configured means dialling is off, 503. Tests are in test/operator.test.ts.

  1. The webhook inbox

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 <id>.<post token> and only the operator can mint them, so a made-up inbox 404s. Reading back needs a separate read token in a header, so the URL you hand a sender can't read anything.

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.

  1. Masking and the measurement corpus

Every JSON response from /api/live, /api/hook, /api/calle and /api/read goes through deepMask on the way out, recursively, on top of the allowlisted projection. Phone-shaped strings get masked wherever they turn up, and fields named for a number get masked whole. Timestamps, scores and ids are left alone (test/mask.test.ts). The key door used to send back the raw payload. It sends the projection now, and /live doesn't echo the destination number anymore.

references/measurements.md is gone. references/observed-shapes.md replaces it, every value in it is invented, and each section says how to see the shape on your own account. Test literals, fixtures and comments that carried times, durations or scores from real calls are rewritten with made-up values that keep the shape.

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. [email protected] is published from that commit with provenance, and the app here depends on ^0.2.2.

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 refs/pull, which only an admin or GitHub Support can clear.

Rebased on main, one commit, validate_repository.py passes, 131 tests, next build clean.

@Ray-56

Ray-56 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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.

@Ray-56

Ray-56 commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

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.

@Ray-56
Ray-56 merged commit fc43128 into CALLE-AI:main Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants