Independent architecture and stack audit
Review date: 2026-10-11. Reviewer: independent architecture audit agent. Human approval: reviewer ___ / date ___ / outcome ___. This is a technical review, not deployment approval. Findings describe the working tree inspected during concurrent development; later fixes need a recorded verification addendum. No remote resources were changed.
Disposition: continue reversible development; do not describe the current implementation as release ready, interoperable, fully stack compliant, or proven inexpensive at scale. The live contact-grant intersection, recovery activation gate, honest disabled federation manifest, and false release gate are sound foundations. The Worker persistence boundary and release UI do not yet satisfy the architecture described in DEVLOG D020. The remaining work includes functional implementation and data-integrity fixes, not just documentation or credentials.
Review basis and limits
Read the actual adjacent stack deck: ethos.md, conventions.md, core/boundaries.md, shapes/service.md, and shapes/spa.md. Checked vendored ngin providers.js and queries.js, including provider leases and one-shot query peeks, rather than assuming a framework API. Inspected DEVLOG, protocol/security/auth/account/contact/notification/storage specifications, the reference core, identity and messaging packages, Worker routes/objects, release frontend/model, storage adapters, release gates, and existing Worker/storage tests.
This review is independent code and design analysis. It did not rerun the full suite or claim a deployed security test, production load test, physical authenticator test, backup drill, or delivery verification. Existing workerd tests establish useful happy-path shelving and transactional-directory evidence; they do not establish the adversarial archive/cleanup properties below. The coordinator owns DEVLOG reconciliation and implementation verification.
The scope recorded in DEVLOG and the review assignment includes no-email accounts, contact capabilities, actual notification delivery, age-sensitive inactivity handling, a general fragment/resource grant system, provider integration, self-hosting, and the 3sln stack. Proposed implementation decisions must not silently reduce those requirements. Human sign-off cells remain blank; their existence does not prohibit authorized reversible implementation.
Findings requiring action
| ID / priority | Evidence and consequence | Required correction and acceptance evidence |
|---|---|---|
| A01 / P0 data integrity | AccountObject.alarm() writes all message/resource records and deletes hot copies. warm() alone rejects archives over 8 MiB or 2,000 rows; addResource() has no resource count quota. A state accepted while warm can therefore be archived successfully and rejected forever when restored. |
Use the same versioned envelope validator and size/count limits before archive commit and during restoration. Oversized snapshots must retain the complete hot copy. Enforce account metadata/resource quotas. Test boundary sizes, excessive rows, failed R2 verification, interrupted writes, and retry after rejection against workerd. |
| A02 / P1 restore trust | Restore checks digest and key prefixes, but not unique keys, embedded owner, key/record ID agreement, blob namespace, allowed record schema, or resource-secret envelope. Digest in the same object detects accidental mutation only if the digest was not also replaced. Stable accounts/{owner}.json has no separately committed generation binding. |
Validate every restored row, reject duplicate/out-of-owner keys and blob references, and bind a versioned immutable snapshot key/digest in the hot marker. Test foreign-owner records, mismatched blob refs, duplicates, malformed credentials and stale snapshot generations. Specify whether integrity protects accidental corruption or hostile storage modification. |
| A03 / P1 persistence and cost | The generic DO store hydrates R2 bytes on every get(message) and performs an R2 PUT whenever those bytes return through set. Reading metadata for notification eligibility downloads content; marking read/tagging downloads and rewrites it. R2 side effects occur inside an SQL transaction callback but are not rolled back with SQL. Blob deletion happens after SQL commit without a persistent retry record. |
Separate immutable blob storage from transactional metadata. Persist blob refs and metadata without hydration. Fetch bytes only through content-read capability. Stage an immutable blob then atomically commit metadata, counters, receipt and outbox. Record compensating orphan/GC work durably, and make cleanup idempotent. Inject R2/SQL failures and crashes around each commit boundary; assert metadata changes use zero blob GET/PUT operations. |
| A04 / P1 issuer correctness | AccountObject constructs contact services and share URLs with literal https://3sln.me, while identity uses env.APP_ORIGIN. Local contacts and self-hosted deployments issue links for another host. |
Inject one validated issuer configuration and URL builder into contact, identity, recovery, and resource identity paths. Keep an explicitly local test profile separate from public HTTPS requirements. Verify a second HTTPS issuer, local origin, wrong audience and cross-issuer rejection. |
| A05 / P1 false capability advertisement | /api/config derives email/push/Google availability from secrets. identity() never supplies a mailer; there is no Worker notification transport/wake path or Google OAuth lifecycle. Supplying keys would enable UI claims that cannot succeed. |
Define explicit implemented/enabled/verified feature state in composition configuration. Wire and verify a transport/flow before returning enabled. Until then fail closed regardless of secret presence. Add config tests with every secret populated. This applies equally to provider connected and contract-conformance claims. |
| A06 / P1 secret handling | Release public pages repeatedly read tokens from location.hash, allow a query-token fallback, and never remove secret-bearing URLs from history. Recovery import sends the file secret without checking its issuer/version against current service configuration. |
Capture bounded tokens in memory before rendering, remove the URL secret with history.replaceState, and remove query fallback from generated/accepted flows unless an explicit migration requires it. Validate recovery file format and issuer before any request. Browser-test history, reload, wrong-issuer imports and absence from persistent caches. |
| A07 / P1 namespace correctness | S3Adapter.list() with configured prefix=private sends prefix=private, including sibling private2 keys; object reads use private/. WebDAVAdapter.list() ignores configured prefix altogether. Both return raw provider XML rather than contract object metadata/cursors. |
Use the same canonical resource root for all methods, enforce delimiter boundaries, normalize bounded provider metadata, and scope opaque cursors to principal/resource/root/filter. Test sibling prefixes, encoded/Unicode keys, response href escape, paging and traversal. Keep adapters unadvertised as conformant until these tests pass. |
| A08 / P1 lifetime/retention | Receipts, revoked grants, contact URLs, outbox events/jobs and unsubscribe records have no cleanup path. Deleting messages frees inbox quota while other records accumulate. Grant creation counts revoked grants toward a permanent 1,000-entry cap. Shelving moves only metadata; blob payloads were already in R2. | Publish retention and retry horizons, bound all record families, and collect expired records without reviving an expired grant or breaking the documented retry window. Include stale encrypted URLs and backups in deletion rules. Test repeated receive/delete cycles and expired-grant replacement. |
| A09 / P1 incomplete scheduling | Worker scheduled() is empty. Notification service functions are not called by any deployed dispatcher. The account alarm currently means seven-day shelving; warm() and touch() overwrite it. |
One persistent account scheduler must choose the earliest notification, quiet-window retry, lease expiry, GC or lifecycle deadline. Keep metadata needed for delivery available while cold, or explicitly restore it before an eligible job. Test alarms interleaved with ordinary owner activity, repeated alarms, expired leases and cold accounts. |
| A10 / P1 architectural compliance | release.mjs owns a global mutable state object, direct API reads, browser capabilities, imperative routing, callback-bearing components and manual render(). Its ngin Request merely wraps a generic request; the session Query listens for session-updated, while mutations publish account-updated. The engine does not own most observable state. |
Move account/session/navigation/async operation state into providers and Queries; domain Actions express specific intentions and publish one agreed event vocabulary. Compose query Cells and watch through the shared stack module. Components render data and bubble native intent events. Preserve existing browser facades/tests while migrating one flow at a time. |
| A11 / P1 authority shape | HTTP validates an owner, then retains an unrestricted AccountObject RPC handle and raw owner ID. The DO owns connector policy, capability construction, signing workflow, quotas and lifecycle rules as well as platform bindings. Existing domain services are useful but do not make the route grant into an object. | Acquire narrow owner/contact/resource capabilities at the application boundary. Only the permitted operations should exist on returned resources; methods close over the validated subject. Keep mutable grant, assignment, collection, quota and expiry checks at the authoritative transaction, including replay. Define how session/account revocation races an already accepted operation across directory/account objects. |
| A12 / P2 scalability bottleneck | Every session lookup and public-message account-status check hits the same directory-v1 DO holding complete credential/session/audit envelopes. Account snapshots scan multiple key families; list sorts all messages and exposes only the first 100 without a cursor. |
Keep the global directory limited to uniqueness/lookup coordination or explicitly bound the initial population and traffic. Design a versioned migration of account authority into per-account storage before scale claims. Add indexed/paged metadata reads and benchmark work per request with realistic account sizes. Do not claim that per-account messaging alone removes the global bottleneck. |
P0 identifies a plausible accepted-state data-availability failure before public users; P1 identifies a release or meaningful architecture boundary; P2 requires honest scaling limits and a planned correction. These priorities are not evidence of external exploitation.
Decision cross-review
| Decision | Independent assessment | Open tradeoff / correction |
|---|---|---|
| D014 contact URLs | Path plus fragment and explicit delivery POST are appropriate. Userinfo syntax is not an account locator. | Mutable handles are routing/display aliases. Stable public subject IDs should remain separate, and handle changes/reuse must not retarget an old contact. Implement fragment removal rather than documenting it only. |
| D015 signing and live scopes | Exact registered claims/header equality, key activity and atomic grant quota checks are stronger than signature-only authorization. A server-signed token cannot manufacture arbitrary accepted authority. | Server custody is a deliberate hosted trust decision, not fulfillment of a user-held-key requirement. UI wording such as “Your keys” needs a custody disclosure. Define key retirement and encryption-key versions/rotation, including archived connector secrets. No public verification-key discovery or independent contact interoperability is established. |
| D019 no-email recovery | A hashed random single-use recovery secret, activation proof, CAS consumption, session revocation and limited replacement setup are coherent. The acknowledgement proves receipt, not durable retention. | Recovery currently clears authenticators before replacement; a user who loses the short setup token mid-recovery may be stranded. Specify a bounded recovery-resume mechanism or prominently establish this failure mode. Verified email login does not currently prove recovery can finish a recovering account. Do not imply phone recovery exists. |
| D018 notifications | Transactional events, leases, final eligibility checks, generic payload and repeated reminders form a useful domain policy. | Delivery is at least once and the final authorization-to-send race remains; distinguish transport acceptance from inbox arrival. Recheck verified destination, bounce suppression and device ownership at delivery. A successful preference save is not evidence of a working transport. |
| D020 platform architecture | Workers + account DO + R2 is a plausible small-service architecture. Identity and messaging rules already accept useful testable interfaces. | AccountObject is presently both platform adapter and domain/service composition, and the UI is a second state engine. Merely wrapping preconstructed services in Provider.fromSingleton does not establish resource graph ownership, narrow authority, or teardown. |
| D021 directory | A single transactional owner makes unique handle/email claims straightforward at low volume. | This is a bounded bootstrap tradeoff. A future sharded unique-claim protocol needs reservations/fencing and crash reconciliation; do not perform a casual split that loses atomic uniqueness. Benchmark before accepting either global or sharded design. |
| D022 inactivity | Reversible metadata shelving and destructive deletion are distinct operations and should stay separate. | The actual seven-day alarm, identity planner’s 180-day/two-year defaults, and recorded user request for two/seven-day new-account and one-year age-scaled policy disagree. The planner’s indefinite retention without email/phone and extra approval predicate are proposed policy choices, not user requirements. Reconcile these in DEVLOG; design no-email warning/recovery UX rather than quietly substituting perpetual retention. |
| D023 storage contract | Capability negotiation and explicit read-presign authority are reasonable if clients request a compatible capability subset. Revocation cannot retract an already issued upstream URL before expiry. | The older contracts spec requires a full write contract and different URLs; implementation returns raw XML and missing metadata/version fields. One normative schema/conformance suite must own the rule. No conditional writes, range support or presign semantics may be inferred just from a provider name or one successful list. |
Internal UUID keys are appropriate storage identifiers; an opaque issuer-scoped JWT subject is not intrinsically insecure. However, the protocol already specifies stable subject/resource URLs and the task calls for URL identities. Define {id: canonical HTTPS URL, localId: private UUID} at the public boundary, retain local UUID routing internally, and never interpret arbitrary caller-supplied resource URLs as fetch targets. Contact sub, account responses and resource descriptors must agree on the chosen published representation. Handle aliases and issuer migration require separate rules.
Fragment and storage model that still needs implementation
The reference BindleEngine only demonstrates one fragment, a method list and current resource assignment. It is memory-only and imports Node crypto. Worker /api/account returns grants: []; no persistent general preference grants, consent flow, federation or gateway connects that reference authorization model to production resources. These remain release scope, not completed functionality waiting for secrets.
Specify one persisted grant schema with immutable fragment URL and digest, approved fields/atomic values, client and subject, resource URL snapshots, exact contract revisions, per-method constraints, assignment/grant revision, expiry and revocation. Never infer access from contract capability metadata. A read-only resource handle must not expose a write method. On every execution and idempotent replay, intersect the live grant, resource ownership, current assignment, implemented capability and constraints. Removal permanently narrows approval; reassignment cannot restore authority without consent.
Resolve these concrete spec disagreements before publishing an immutable contract: object-store versus object-storage URLs; mandatory full write conformance versus negotiated capabilities; etag/value/ifMatch reference shapes versus version/dataBase64/condition; modifiedAt RFC 3339 in the older document versus the stack’s epoch-millisecond stored data convention; permitted Unicode keys versus adapter restrictions on slashes/percent sequences. JWT iat/exp seconds are protocol fields and should retain their specified wire format rather than be confused with application timestamps.
A provider schema being discoverable does not establish correct behavior. Ship shared machine schemas, invalid-case vectors, a memory fixture and hosted fixture first; run the same vectors against each adapter. Include two-client conflicts, stale conditions, root isolation, timeout/unknown outcomes, range bounds and cursor replay. The present descriptor is explicitly a human-readable draft, not a complete schema.
Lowest-complexity cost architecture to evaluate
Use Worker surfaces for parse/serialize and early request limits; account-scoped authority and transactionally indexed metadata in SQLite DOs; immutable payload bytes in private R2; provider adapters for outbound storage/mail/push; one account scheduler for durable due work. Prefer a bounded alarm-driven outbox initially; add a queue when measured volume, delivery latency isolation or backpressure justifies it. Keep the authority and delivery service interfaces independent of that choice.
There is no defensible “cheapest” claim without traffic distribution, metadata sizes, account churn, pending-job counts and wake frequencies. Current Cloudflare pricing meters DO RPCs, active duration and SQLite operations; each setAlarm is a row write. Idle objects eligible for hibernation do not incur duration charges. This makes repeated touch writes and long R2 I/O inside a DO meaningful costs even before account count becomes large. Cloudflare DO pricing.
An account has one alarm, with at-least-once execution and bounded automatic retries. A persistent minimum-deadline scheduler is therefore required when retention and notifications share an object; an ordinary request must never move a due delivery into the future. Record terminal failures and reschedule deliberately after automatic retries are exhausted. Cloudflare alarm semantics.
R2 Standard currently charges $0.015/GB-month plus Class A/B operations; Infrequent Access has retrieval charges and a 30-day minimum. Moving tiny metadata snapshots every seven days is not automatically a saving. Payloads already in R2 gain no storage saving from deleting their DO metadata. Keep Standard as the default to evaluate for mutable archives rather than selecting “cold” by name. Cloudflare R2 pricing.
Measure a break-even condition: hot metadata bytes actually removed × idle duration × storage-rate difference must exceed snapshot/write/read, deletion/restoration and DO-duration costs. Preserve all included-tier and rounding effects in the calculation. Storage metadata remains unless an object is fully cleared; partial eviction does not make a per-account object free. Cloudflare cleanup guidance.
Do not fully clear identity/recovery/capability metadata just to save object overhead. Separating disposable cold-cache state into another object is only justified if measured savings exceed extra routing, objects and consistency complexity. A single global cron scanning every account is likewise not inherently cheaper than sparse account alarms. Measure both under the expected active-to-dormant ratio.
Concrete migration sequence
- Preserve behavior and close correctness holes. Keep Worker routes and the DOM entry point as facades. Add adversarial archive/cross-store tests, capability-readiness tests, issuer tests, secret-history tests and provider-root tests. Fix A01–A09 without changing user-visible semantics accidentally. Existing core/security tests must continue driving the same facades.
- Extract the Worker application boundary. Introduce named injected resources for account metadata, blobs, identity authority, crypto/key custody, clock, scheduler, delivery and connector factory. Move account/contact/resource/lifecycle policy into domain modules. Keep
env, R2, DurableObject, QR encoding and concrete connector construction in composition/providers. Use named dependencies rather than passing Worker env as context. - Acquire authority as a resource. A request leases a narrowly scoped account/message/storage capability that owns subject and permitted operations. Contact delivery acquires a receive-only resource; owner message read/update/delete are distinct capabilities. Return no raw unrestricted storage handle to a route. Live mutable permission checks remain inside the authoritative transaction, so acquiring a handle cannot freeze an old approval indefinitely.
- Bring release frontend state into ngin. First migrate account snapshot and session invalidation; then auth/recovery; then navigation/modal/operation state. Inject network, credentials, download/clipboard, browser location, storage and notifications as resources where needed. Add a shared observable-to-Cell bridge using actual vendored APIs. Components receive data/UI capabilities and emit native events; compositions translate to actions. Remove direct
refresh()/manual rerender as each slice enters the graph. Test account switching, unauthorized responses, stale fetch completion, modal focus and offline transitions. - Complete missing product services against the new boundary. Persistent preference grants, conformance gateway, delivered notification/recovery mail, real OAuth/provider adapters and requested retention scheduling must each have explicit behavior and verification. Avoid a generic
Request(path, body)becoming the only domain action, because it leaves every policy in the components. - Measure and sign off. Benchmark global-directory and per-account workloads, metadata/blob operation counts, restore memory and duration, retry amplification and job backlog. Run
npm test, DOM tests and real-browser flows after relevant code changes. Reconcile DEVLOG/spec differences, then request only the actual remaining human decisions. Release gates remain false until supported by concrete evidence.
The service need not force every one-shot HTTP read through a live Query: the stack itself explicitly permits direct facade calls/one-shot leases. Pure parsing, formatting and URL construction helpers also do not need providers. The correction is ownership of resources, rules and observable state; adding framework ceremony without moving those owners would not fix this review.
Review checkpoints
| Checkpoint | Technical outcome | Human reviewer / date / outcome |
|---|---|---|
| Contact URL/signing custody/live intersection | Core direction supported; issuer/history/custody and key-lifecycle corrections required | ___ / ___ / ___ |
| Recovery without email/phone | Core proof/CAS model supported; wrong-issuer import and interrupted-recovery behavior need closure | ___ / ___ / ___ |
| Archive/storage integrity | Changes required, including A01 before release | ___ / ___ / ___ |
| Stack boundary compliance | Changes required in release UI and Worker authority composition | ___ / ___ / ___ |
| General grants/storage interoperability | Not implemented end to end; normative contract reconciliation required | ___ / ___ / ___ |
| Notification/retention/cost architecture | Alarm/outbox direction supported provisionally; implementation, policy reconciliation and measurement outstanding | ___ / ___ / ___ |
| Publication readiness | Not established | ___ / ___ / ___ |
No human approval is inferred from this review, successful unit tests, prior design choices or concurrent implementation.