# Changelog ## Unreleased Fixes from the annoyance hunt (`hunt/HUNT-REPORT.md`), with cases in `tests/fixtures/access_parity.json`: - Sign-up keeps names in every script (H-05). Profile text is NFC normalized and keeps combining marks, so Hindi, Tamil, Thai and Arabic names keep their vowel signs and harakat, and a decomposed "José" stays José. Control and format characters are refused (the zero-width joiners Persian and Indic names need are kept), other symbols are still removed, and lengths count code points in both implementations. - The personal-data screen reads numbers in any digits (H-06). In JavaScript `\d` and `\b` match ASCII only, so the hosted Worker missed phone, identity and card numbers written in Arabic-Indic or full-width digits that Python caught, and neither caught full-width email domains. Both now fold the screened text (NFKC, then every decimal digit as 0-9) and match with ASCII-only patterns, so they agree on the hunt's samples, which are now a parity case. - Every JSON body's numbers are checked, whatever its Content-Type says (H-07). The Worker checked numbers only when the body said it was JSON, but parsed every body: a task posted without a Content-Type with `"not_before": 1e999` was stored as Infinity and could never be claimed, and a 30-digit integer sent as `text/plain` was silently rounded. Both implementations now read JSON routes' bodies as JSON either way and refuse non-finite numbers and integers that don't survive IEEE-754; Python used to refuse a valid body sent without a Content-Type. The Worker's 16 MiB upload cap now belongs to the blob route itself, not to any path containing "/blobs" (H-65). - An enqueue replay must be the same request from the same principal (H-08). In both implementations the same `Idempotency-Key` (or task id) with a different brief returned the first task's id, and a different principal reusing it got the first principal's task back, so its own work was silently not filed. Only an exact replay returns the first id now; anything else answers 409 `idempotency_conflict`, as submit already did. - `/connect.json` publishes the requester profile rules (H-09): field lengths, the cause count, the normalization, the kept characters (with a Unicode pattern), what is removed and what is refused, built from the constants sign-up enforces, so a client such as Eranos's request box can check a profile before sending it. - Verb listings reach every visible verb (H-14). The Worker read the 1,000 oldest rows and only then filtered for what the caller may see, so newer open verbs could hide behind older hidden ones. Both implementations now page through rows until the page fills, read at most 10,000 rows per request, and return `next`, a cursor to pass back as `after` on `/v1/verbs` (the MCP tool's schema is part of the frozen tool benchmark, so it keeps its arguments); `limit` defaults to 100 and stops at 200 in both, `status=all` works on the Worker too, and a non-integer `limit` or a bad cursor answers 400 in both. - JSON bodies are bounded the same way in both implementations (H-58, H-59, H-60). A body nests at most 64 containers deep: in Python a brief 300 levels deep was accepted and then made every claim of its queue answer 500 until it dead-lettered. Strings must be well-formed Unicode (a lone surrogate answered 500 in Python and was stored verbatim by the Worker). Python now applies the Worker's byte caps before authentication: 64 KiB for anonymous sessions, 16 MiB for blob and bucket-object uploads and 1 MiB for everything else (a 50 MiB body used to be read in full first); the Worker's bucket-object uploads get the 16 MiB storage limit too. - Tags deduplicate in linear time and are capped (H-57): removing duplicates was quadratic (60,000 tags took 15.5 s in Python and 4.9 s on the Worker, stalling the shared namespace), and nothing capped them. A caller now names at most 64 tags of at most 128 characters each on a task, record or split child, in both implementations. - Python answers 400, not 500, for a record key, store name or tags that fail validation inside a route (H-59), as the Worker does; non-finite and inexact numbers were already covered above. - HEAD answers wherever GET does, with GET's status and headers and no body (H-53); `/connect.json`, task views and the health page had answered HEAD with 404. `/mcp` keeps its own method rules. - A path with a cut-short or non-UTF-8 percent escape answers 400 in both implementations (H-54); the Worker had answered 500 for a broken escape in a task id, blob hash, feed name or namespace. - Write routes answer other methods with 405 and `Allow: POST` (H-55). The sign-in and requester sign-up addresses had answered GET with 404 "no route", and Python's existing 405s named no allowed method. The Worker's 404 now repeats at most 200 characters of an unknown path; it had echoed paths of any length. - One length rule and whole-string patterns (the rest of H-65). The Worker counted an idempotency key's UTF-16 units, so 200 emoji passed in Python and were refused on the Worker; both now count code points. Python's bucket-name and object-key checks accepted a trailing newline, and a path ending in `%0A` reached its route with the newline dropped: a JSON-typed upload to `a/b%0A` was stored as `a/b`, and a raw one was refused as invalid JSON. The checks now match the whole string, and both implementations refuse a path with a control character with 400. - Python's unexpected faults reach the audit trail (H-61). The access boundary audited only Tessera's own errors, so a 500 from a fault no handler names was never counted; it now counts as `500:internal`, as the Worker counts a fault in its storage object, before the server answers 500 and logs it. Access follow-ups after the re-review: - Every access description agrees with the switches (F-054). On the Worker the agent card, `llms-full.txt`, MCP `explain_access_modes` and the MCP sign-in hint said `requester.enabled: false` while `/connect.json` said `true`, because they built the description without the requester switch; the Python connect page did the same. All of them now read both switches from one place. - An expired demo or requester namespace now really shrinks (F-050). Its purge alarm deleted everything and then rebuilt the whole schema at once, so each purged object kept about 320 KB (74 tables and indexes) forever. The purge now deletes the object's storage and its alarm and leaves it empty (4 KB in local measurement); the schema comes back only if the namespace is used again. - An untagged split child now carries its parent's tags with its own `kind:` tag, in both implementations; a build child of a request had been tagged `kind:request`. - A research output can cite public contact details. The wider personal-data screen had refused any anonymous submission quoting a public agency's phone number or street address. A phone number or address that appears in a cited source quote (a `quote` field of at least 20 characters), or in text repeating it, now passes; email addresses, identity, card and IBAN numbers never do, and requester inputs keep refusing all of them. An IBAN must now carry at least ten digits, so bill names such as "AB12 RENT CAPS" are no longer read as one. Review fixes for open verbs and requester keys, with the same cases run against both implementations from `tests/fixtures/access_parity.json`: - Governance tags come only from a producer's explicit tags. The Worker had added tags found in a brief (`tags`, `metadata_tags`, `kind` and similar fields), so a requester could open its own request verb to anonymous sessions by writing `open:anonymous` into the brief. Brief-derived tags now never carry `open:`, `status:`, `origin:`, `untrusted:` or `demo:`, and a request verb's governance is fixed after normalization. Split children on the Worker now inherit their parent's owner, surface, visibility and (unless they name their own) tags, as they do in Python; a child may not set the rest. - An anonymous session in the shared namespace reads only its own submissions. Before, once it had any result row on a verb (a failed attempt was enough), `results` returned every later submission, a keyed worker's output and key among them, and claim events listed every attempt. Results are now filtered to the session's own, claim events are closed to open-verb sessions, and task views shown to any anonymous session name no owner, parker or idempotency key. - Empty claim polls no longer spend claim allowances. Five polls with nothing ready had locked a session out of open verbs for the hour (and two such sessions locked out their whole IP hash). Claim limits are now checked before a lease is chosen and charged only when one is granted, in the claim's own transaction. On the Worker every hourly limit of an anonymous or requester key is counted once per key in the primary object; before, each namespace object kept its own counts, so a key got a fresh allowance in each. - A payload `namespace` no longer redirects a keyed write in Python. With the request in the shared namespace, the store wrote a task or record into whatever namespace its body named, so a requester could file into another namespace. Keyed callers must now name the namespace in the request itself (`X-Namespace` or `/ns//`); the Python client does so from the spec. The Worker already routed by the request. - Requester verbs are narrower: they always get the default priority 50, and a requester's own task ids and idempotency keys must start with `rq--`, so one organization can neither collide with another's ids nor learn which exist. `/v1/me` and the audit now answer a requester in the shared namespace. The personal-data screen now covers every stored field (task ids, idempotency keys, record keys and tags) and catches phone numbers, street addresses and IBANs as well, in both implementations. - Requester storage is bounded. On the Worker each sign-up had created a new `org.*` namespace object (about half a megabyte in local measurements) that was never removed, and idempotency keys had no length limit (a 200,000-character one was stored). A sign-up now writes only the primary object, its profile stored with the key in one transaction; the namespace object is created by the key's first call there and purges itself when the key expires. Expired requester keys, profiles and namespaces are purged in both implementations, idempotency keys are capped at 256 characters for enqueue and submit, from a body or a header, in both, and a failed Python sign-up now removes its key and refunds the allowance. `/connect.json` on the Worker says whether requester sign-up is enabled. - Per-IP limits can no longer be reset by the client. `tessera serve` had trusted `X-Forwarded-For` from any peer, so each request could name a fresh address (eight sign-ups against a cap of three); it now believes forwarded addresses only from `TESSERA_TRUSTED_PROXIES` (none by default). In both implementations a per-IP limit now counts in the hour its daily-rotated IP hash was computed, so a request identified just before midnight UTC and charged just after cannot spend a second allowance. - The Python MCP door now completes the handshake (`initialize`, `tools/list`) for anonymous and requester bearers in the shared namespace, as the Worker does; it had answered 403, so real MCP clients could not connect there. - Three tests now prove what they claim: the check that no key is stored in plain text reads SQLite's write-ahead log too (fresh rows live there, so it had passed vacuously), the Worker test that a requester cannot read its request's children now runs with children it owns (so only the `parent_id` rule refuses them), and the Worker's anonymous-approval refusal is exercised through a real submit instead of a fixture call. Requester keys: `POST /v1/session/requester` lets an organization sign up without an operator, on the Worker (behind `REQUESTER_SIGNUP_ENABLED`) and the Python service (behind `TESSERA_REQUESTER_SIGNUP_ENABLED`). It mints an ordinary hashed key whose fixed scope writes private nouns only in its own `org.` namespace (the profile noun among them), files `request` verbs into `eq01` of the shared namespace and reads only the verbs it filed. Personal and payment data are refused, and sign-ups, filed verbs, requests and bytes are capped. Open verbs: a producer can tag a verb `open:anonymous` so that a tracked anonymous session can claim, heartbeat and submit it in the shared namespace, on the Worker and the Python service. Everything else there stays closed to anonymous sessions: they read only their own claimed work, never other outputs, nouns or principals, and `eq03`, `frontier.review` and `frontier.verify` verbs stay closed whatever their tags say. The store refuses an `eq03` approval completed by an anonymous session. Open-verb claims are capped at 5 per session and 10 per IP hash each hour, receipts carry the session's anonymous labels, and a claimant sees related tasks' states but not their outputs. `/connect.json` publishes the rule and the caps. Agent pages now render every tool list and tool count from the registry that MCP `tools/list` serves, on the Worker and the Python service: `llms.txt` lists every served tool, `llms-full.txt` adds a generated one-line reference per tool, and the agent card carries the served tool objects. The hosted `llms.txt` had named 26 of its 37 tools. The Python agent card's tool objects now use the `tools/list` field names (`inputSchema`, `outputSchema`) and the card reports `tool_count`. New submissions capture immutable execution records with optional worker-reported harness, model, summary, contract and usage metadata. Authorized workers can recall prior runs, list a task's run references and read them through HTTP, MCP or the Python client without a shim. Recall never automatically reuses an output. Anonymous retention and source access checks apply. The execution guide includes a dry-run-by-default external-worker distillation task example; new packs remain deferred until infrastructure readiness. External agents can now run tracked anonymous Tessera loops; keyed access gains account/token audit. Anonymous access is feature flagged, uses private demo namespaces and retains the existing authenticated security boundary. A key can now always read back raw blob bytes it uploaded, on the Worker and the Python service, even when another key stored the same bytes privately first; before, it got `201` and then `404`, which showed that someone held those bytes. Python object reads now follow the object's own ACL, as on the Worker, instead of the raw blob row written by the bytes' first uploader. The rest of first-uploader blob governance is still open; see `docs/raw-blob-governance.md`.