These are the actual records written while building Argentic, each one before the code it describes, in design sessions between me and Kiro. They are published as written, with personal identifiers (AWS account IDs, Cognito identity IDs, personal bucket names) redacted. A later decision that supersedes an earlier one says so in the record.
- ADR-001: v1 architecture: background upload, Cognito + Sign in with Apple, gallery, Intelligent-Tiering, on-device thumbnails
- ADR-002: Agentic AI: on-device embeddings, S3 Vectors index, device-direct Bedrock agent with cost guardrails
- ADR-003: Multi-tenant isolation by Cognito identity prefix and per-tenant entitlements
- ADR-004: Bring-your-own-AWS distribution: one CloudFormation stack and a setup code
- ADR-005: In-app account deletion with no developer backend (App Store 5.1.1(v))
ADR-001: v1 Architecture — Background Upload, Cognito/Apple, Gallery
Status: Accepted
Date: 2026-07-17
Context: v0 shipped to TestFlight. User testing revealed: (a) upload freezes when app goes to background/locked, (b) desire for in-app gallery to browse uploaded photos, (c) preference to consolidate into the existing <personal-bucket> bucket (which already holds Google Takeout .zip archives in Glacier Deep Archive), (d) cost-optimize with Intelligent-Tiering instead of lifecycle→Glacier IR.
Decisions
D1 — Background Upload
Choice: Background upload via device-presigned URLs + URLSession background transfer + BGProcessingTask.
Rationale: iOS kills foreground-only uploads within seconds of app backgrounding. URLSession background transfers survive app termination and device lock — the OS drives the upload. BGProcessingTask schedules batch work (e.g., 500 new photos) when on Wi-Fi + power.
Rejected: Foreground-only upload (v0 approach) — unusable for large libraries.
D2 — Consolidate Into Existing Bucket
Choice: Point app at the existing <personal-bucket> bucket (eu-west-1) under prefix photos/; migrate the few objects already in photo-backup-<ACCOUNT_ID>-eu-west-1.
Rationale: Single bucket simplifies IAM, billing, lifecycle. The existing Google Takeout .zip archives in Glacier Deep Archive are untouched and NOT browsable as individual photos — they stay as-is under their current prefixes.
Rejected: Keep separate bucket (extra IAM, extra cost monitoring, no benefit for single user).
D3 — In-App Gallery (Read-Only)
Choice: Gallery view showing thumbnails stored in S3; tap to re-download original via presigned GET.
Rationale: User wants to browse what’s been backed up without opening the AWS console. Read-only — no edit/delete from phone. Requires s3:GetObject in the Cognito role’s policy.
Rejected: No gallery / console-only (poor UX for personal photo browsing).
D4 — S3 Intelligent-Tiering Storage Class
Choice: Upload objects directly in INTELLIGENT_TIERING storage class.
Rationale: Instant access for gallery + automatic cost optimization (~$0.0025/1000 obj/mo monitoring fee). Photos untouched for 90/180 days auto-tier to Archive/Deep Archive. Replaces v0’s lifecycle rule (Standard→Glacier IR at 30d) which blocked instant gallery reads.
Rejected: Standard + lifecycle rule (blocks gallery); direct Glacier (no instant access).
D5 — Auth: Cognito Identity Pool + Sign in with Apple
Choice: Cognito Identity Pool (enhanced flow) federated with Sign in with Apple → temporary STS credentials on device. Device self-presigns PUT/GET URLs locally. NO custom Lambda or API Gateway needed.
Rationale: Removes long-lived IAM key from phone. STS creds auto-expire (1h default). Sign in with Apple is mandatory for iOS apps with third-party login, and here it’s the only login method — simplest path. Device-local presigning means zero server infrastructure.
Rejected: (a) Static IAM key in Keychain (v0 — no expiry, single point of compromise); (b) Custom Lambda presigner (unnecessary server component; adds latency + cost + maintenance).
D6 — On-Device Thumbnail Generation
Choice: Generate thumbnails on-device at upload time; store under thumbs/ prefix in the same bucket.
Rationale: No server-side compute needed. Device has the original bytes at upload time — downscale to ~300px JPEG is trivial and fast. Gallery loads thumbnails (small) for browsing; originals only on tap.
Rejected: (a) Server-side Lambda thumbnailer (adds infra, S3 event trigger, IAM, cold starts — overkill for single user); (b) On-device downscale-on-view (re-downloads full original every time gallery scrolls — expensive and slow).
Key Architectural Consequence
Background URLSession requires a static, fully-formed URLRequest at enqueue time (the app may be terminated before the upload completes). This means presigned URLs must be generated before handing off to the OS. With Cognito temporary creds, the device presigns locally using the STS session — no server roundtrip. The entire upload path is serverless and device-driven.
Infrastructure Summary
| Resource | Value |
|---|---|
| AWS Account | <ACCOUNT_ID> |
| Region | eu-west-1 |
| Bucket | <personal-bucket> |
| Photo prefix | photos/ |
| Thumbnail prefix | thumbs/ |
| Storage class | INTELLIGENT_TIERING |
| Auth | Cognito Identity Pool + Sign in with Apple |
| Server components | None (zero Lambda, zero API Gateway) |
ADR-002: v2 Agentic AI — Semantic Search + Chat Agent
Status: Accepted Date: 2026-07-20 Context: v1 (+1.2 feature batch) shipped: background-resumable sync, Cognito/Apple auth, gallery with search/sort/delete, video thumbnails, sync UX. User goal: leverage agentic AI for a “bankable / wow” differentiator. Decided through a 5-question inception (2026-07-20). Overriding constraint carried from v1: zero server components — the device talks to AWS directly with scoped Cognito STS credentials.
Decisions
D1 — Metadata Generation: On-Device Pipeline, Cloud On-Demand (option C)
Choice: Per-photo captions/tags/OCR + embedding vectors are generated on-device (Apple Vision + a CoreML embedding model) at upload time and by a self-healing backfill. Bedrock multimodal is used only on demand, when the chat agent needs to look at a specific photo (inspectPhoto tool sends a single thumbnail).
Rationale: The per-photo pipeline runs on EVERY asset forever — on-device makes its marginal cost zero, works offline during sync, and keeps photo content off third-party models (“private AI backup” is part of the pitch). Bedrock’s richer scene understanding is reserved for the few images a conversation actually touches.
Rejected: (a) Bedrock captioning of every photo — per-photo cost forever, network-bound sync, weakens the privacy story; (b) on-device only — loses rich descriptions in chat.
D2 — Search Index: Amazon S3 Vectors (option B)
Choice: A dedicated S3 Vectors vector bucket + index. Device does put-vectors at upload/backfill and query-vectors for semantic search, directly with Cognito credentials (IAM namespace s3vectors:*, scoped to the one index). Captions/S3 keys travel as vector metadata — no separate metadata store.
Rationale: Exactly the service’s design center (cost-effective storage, occasional queries, ~100ms warm). Offloads similarity search from the device, scales past what in-memory search handles, keeps zero-server, and extends the S3-native story.
Rejected: (a) JSONL index shards in <personal-bucket> — offline-capable but DIY similarity, full re-download/rewrite churn, device-bound scaling; (b) dual-write both — unproven offline need, double complexity.
Locked at implementation, not now: eu-west-1 availability check (fallback: nearest region — it’s metadata, not photos); index dimension/distanceMetric are immutable and fixed by the chosen embedding model — verify before create-index.
D3 — Agent Model: Small/Fast Class + Cost Guardrails (option A)
Choice: A small, fast, tool-calling, vision-capable Bedrock model (Haiku / Nova Lite class), invoked directly from the device with Cognito credentials.
Guardrails (day 1): (1) IAM scoped to the single chosen model ID — no bedrock:*; (2) max_tokens cap + bounded conversation history in-app; (3) billing alarm on the account’s Bedrock usage.
Rationale: The agent orchestrates simple tools — frontier reasoning buys nothing visible; the small class costs cents/conversation at instant latency.
Rejected: (a) frontier class — 5-20× cost, invisible gain; (b) dynamic tiering — v1 complexity.
Locked at implementation: exact model ID (regional availability + account opt-in verified live — IAM alone does not grant model access).
Revisited 2026-10-06 (model kept: Claude Haiku 4.5): the frontier cost argument above is out of date — Sonnet 5.5 costs 2× Haiku and Opus 5.5 4× (Price List, eu-west-1), about $0.02 and $0.04 per assistant exchange against $0.01. Sonnet 5.5 was tested against Haiku on the assistant evaluation set (eval/assistant/, checks frozen and decision rule written before the decisive run, 60 turns per model): quality 100 % vs 88.9 %, but first text 2.55 s vs 0.55 s, beyond the “at most twice” bar, so Haiku stays; Opus 5.5 was not tested (4× the cost for tools this simple). Rerun the set before any model change; a change also needs a stack update (IAM on the new profile) and an AIConsent bump. Same day: the apac. profile prefix did not exist for Haiku 4.5, so the assistant failed on every Asia-Pacific stack; Tokyo now uses jp., Sydney au., Singapore global. (ADR-004).
D4 — Agent Toolset v1: Read-Only + Proposal Pattern (option A)
Choice: v1 tools are read-only: searchPhotos (semantic via S3 Vectors), listMonths, storageSummary, pendingCount, inspectPhoto (Bedrock vision, single thumbnail). The agent may propose a deletion selection rendered as a reviewable photo grid — but execution goes through the normal UI, never the agent.
Rationale: The bucket is unversioned; deletes are permanent. The dangerous scenario is precisely an AI-composed bulk delete confirmed with one hasty tap. The demo wow (find/describe/count) needs none of that risk.
Deferred to v2: agent-executed delete — prerequisite: S3 versioning safety net on photos/ (same gate as the “Free Up Space” backlog item).
D5 — Delivery Order: Foundation → Search → Agent (option A)
Choice: Three TestFlight-shippable slices — see PLAN-v2.md. Foundation (pipeline + S3 Vectors + backfill of existing assets), then semantic search in the existing search bar, then the chat agent. Rationale: Each slice verifiable on device before the next; the agent launches with full power instead of a keyword-only half-version that disappoints at the first real question. Rejected: agent-first without embeddings — “montre-moi le coucher de soleil” failing in the first demo is the opposite of wow.
D6 — Consent Before Anything Is Sent to the Model (added 2026-09-28, 1.5.1)
Context: App Review guideline 5.1.2(i) asks apps to disclose where personal data goes, “including with third-party AI”, and to get explicit permission first. Until 1.5, no app text named Claude or Anthropic, a suggestion-card tap counted as consent, and the monthly recap (Memories) sent captions to the model with no user action. Whether Apple counts Claude-in-the-user’s-own-AWS-account as “third-party AI” is unverified; the app assumes it does.
Choice: One versioned consent (AIConsent, UserDefaults: version 1, date, the Bedrock zone the screen showed, and the member who accepted) covers the assistant and the recap. The consent screen (AIConsentSheet) names Claude (Anthropic) and Amazon Bedrock, lists what is sent and what never is, and shows the Bedrock geography derived from the same rule as the model ID. The consent counts only for that version, that zone and the member who accepted it (a SHA-256 digest of their Cognito identity, which belongs to one Apple ID in one stack): connecting a stack in another zone, a first stack after accepting without one, or someone else signing in on the iPhone after a Sign Out, asks again. Checked in four places: PhotoAgent.submit(_:from:), the single entry of the four ways into the assistant (it holds the message back and opens the screen); the start of every Bedrock round in PhotoAgent (so a direct call, or a withdrawal during a turn, sends nothing more); the start of MemoryGenerator.generateIfNeeded (before any S3 read); and right before the recap’s Converse call (a withdrawal while the bucket is read). Settings → Assistant shows the state and withdraws or gives the consent.
Rule for successors: bump AIConsent.currentVersion whenever what reaches a model changes (a new tool result, another model, on-device model work): every user is then asked again. Update the consent screen, docs/privacy-policy.md and the Settings footer in the same change.
Rejected: (a) consent inferred from a tap on a card or a starter prompt: not the explicit permission 5.1.2(i) asks for; (b) a confirmation before every message: heavy, and the recap has no message to confirm; (c) a check in the views only: any other caller, or a later entry point, would bypass it; (d) a separate consent for the recap: two answers for one disclosure; one consent covers both, and without it the recap stops (reversible if a second switch is wanted later).
Architecture Summary (v2 additions)
| Resource | Value |
|---|---|
| Vector store | S3 Vectors (dedicated vector bucket + 1 index) |
| Embeddings & captions | Generated on-device (Vision + CoreML), pushed at upload/backfill |
| Agent LLM | Bedrock, small class (exact model ID TBD at impl), device-direct |
| New IAM (Cognito role) | s3vectors:PutVectors/QueryVectors/GetVectors on the index; bedrock:InvokeModel on ONE model ID |
| Server components | Still none |
| Cost guardrails | Scoped IAM, max_tokens cap, bounded history, Bedrock billing alarm |
ADR-003: Multi-Tenant Isolation by Cognito Identity Prefix
Status: Accepted · Date: 2026-07-21
Context: A second family member wanted to back up their own photos. The
app was single-user in practice: one IAM role scoped to photos/* meant any
signed-in Apple ID would read — and could delete — everyone’s photos.
Decision
Isolation per tenant using the canonical AWS pattern — Cognito policy variables:
- S3 layout: every object lives under its owner’s identity —
photos/<identityId>/<YYYY>/<MM>/<asset>/<file>andthumbs/<identityId>/…. The identityId comes from the Cognito Identity Pool and is stable per Apple ID. - IAM: the authenticated role’s resources become
photos/${cognito-identity.amazonaws.com:sub}/*(same for thumbs, plus a prefix-conditionedListBucket). Isolation is enforced by AWS, not by app code: a tenant cannot read or delete another tenant’s objects even with a raw S3 client. - Per-tenant entitlements: an admin-managed JSON at
config/<identityId>/entitlements.json(read-only for the tenant) gates product capabilities: AI assistant, semantic search, monthly memories, delete, and a soft storage quota. Missing file = everything enabled. - Vectors: the S3 Vectors index is shared; every vector carries a
tenantIdmetadata key and every query filters on it. (IAM granularity stops at the index level — app-level filtering is an accepted trade-off for a household threat model; strong isolation would be one index per tenant.)
⚠ Known security limitation — vector isolation (v1)
S3 object isolation (photos, thumbnails, config) is enforced by IAM: the
${cognito-identity.amazonaws.com:sub} policy variable confines each tenant
to their prefix, and a modified client cannot escape it.
The S3 Vectors index is shared, and s3vectors:QueryVectors grants
access to the whole index — the tenantId filter is applied by the app,
not by IAM. A member of the same Identity Pool running a modified client
(filter removed) could therefore read other tenants’ search metadata —
Vision captions, OCR text and photo keys — but never the images
themselves (those stay IAM-protected on S3).
- Household threat model = acceptable: a shared setup code is already a trust invitation between family members.
- Public BYO-AWS distribution: documented explicitly in the README — “a setup code grants read access to every member’s search metadata in the same deployment.” S3 Vectors does not (yet) support IAM policy variables on a sub-prefix, so there is no immediate IAM-level fix.
- Future strong isolation: one vector bucket/index per tenant, at the cost of extra operations. Not adopted in v1 (family context).
Sequencing trap (learned the hard way)
Deploying the strict IAM role before shipping the new app version would have broken the installed app (which still wrote legacy un-prefixed keys). The mandatory order was: migrate existing objects server-side → ship the new app → THEN deploy the strict role. Also: rebuilt keys must match the new KeyBuilder exactly, or the sync ledger re-uploads the entire library.
Rejected
- Bucket/stack per user — total isolation but doubles infra and operations for marginal gain in a family context.
- Shared library — out of scope: each member wants their own backup.
ADR-004: Bring-Your-Own-AWS Distribution
Status: Accepted · Date: 2026-07-21 Context: Making the app usable beyond its author. A hosted SaaS was considered and rejected: it would require a real backend, billing, GDPR custody of other people’s photos, and would destroy the differentiator — no servers to run, the app talks directly to AWS.
Decision
The user’s AWS account is the backend. Distribution model:
- One CloudFormation stack (
infra/argentic-stack.yaml) creates everything: a dedicated photos bucket (DeletionPolicy: Retain— user data survives the stack), the S3 Vectors index, the Sign in with Apple identity pool, the tenant-isolated IAM role (ADR-003), and an optional Bedrock cost alarm.CAPABILITY_IAMonly. - Setup code: the stack emits a single versioned output —
argentic1|<region>|<identityPoolId>|<bucket>|<vectorBucket>|<vectorIndex>— that the user pastes into the app. The in-app parser is whitespace-tolerant and validates pool/region coherence. Switching deployments signs the user out (identities are pool-specific) and resets the sync ledger (it referred to the previous bucket). - Template distribution: public GitHub only. The CloudFormation console
“quick-create” link was dropped — its
templateURLparameter only accepts S3 URLs and no public hosting bucket was available. Retained paths: console Upload a template file (screenshots in the README) or a copy-paste CloudShell block. Both are terminal-free on the user’s machine. Update, 2026-09-28: the repository went private; the current template and the step-by-step guide are on the deployment guide. - Region portability: the Bedrock cross-region inference profile prefix
(
us./eu./jp./au./global.) is derived from the deployment region — both in the template (region→geo mapping) and in the app. Until 2026-10-06 the table usedapac., which does not exist for Claude Haiku 4.5: the assistant failed on every Asia-Pacific stack. A test now checks the template table against the app’s. - Household model: the setup code acts as a household invite — anyone holding it can store photos in the owner’s bucket (at the owner’s cost), each confined to their own prefix. Documented rather than prevented: it is a feature for families, and entitlements/quotas give the account owner control.
Also learned
- IAM role descriptions accept ASCII only (an em-dash failed the first stack deployment).
- A retained bucket survives stack rollback too — redeploying after a failed first attempt requires deleting the orphan bucket manually.
ADR-005 — In-app account deletion semantics (App Store 5.1.1(v))
Date: 2026-07-28 · Status: accepted · Version: 1.5 (34)
Context
App Store Review Guideline 5.1.1(v) requires any app that supports account creation to offer account deletion within the app (enforced since 2022-06-30). Argentic creates accounts via Sign in with Apple → Cognito Identity Pool. The twist: Argentic has no developer backend — every byte lives on the device or in the user’s own AWS account (ADR-001, ADR-004). “Delete the account from the developer’s records” is therefore empty by construction; what users actually need deleted is their tenant data in their own bucket, and all local app state.
Decision
Settings → Delete Account… (confirmation dialog, destructive) runs
AccountDeleter.deleteAccount():
- S3 purge — every object under the member’s tenant prefixes
photos/<identityId>/andthumbs/<identityId>/in the user’s bucket. Uses the existing IAM grants (s3:ListBucketands3:DeleteObjectscoped to those two prefixes) — no stack change.config/<identityId>/is not purged: it holds the member’s entitlements, written by the stack’s administrator. The member’s role may only read it (no list, no delete), and its absence would lift the member’s quotas (ADR-003). Until 1.5.1 the app also listedconfig/; the role refuses that list, so on every deployed stack the deletion stopped after the photos, with no local erasure and no sign-out (P0.8). - Vector purge — the member’s entries in the shared S3 Vectors index
(vector keys are the tenant-prefixed photo keys). Best effort: stacks
predating
s3vectors:DeleteVectorscan’t delete; the outcome is surfaced to the user, and leftover metadata is inert, image-free, and inside their own account. - Local erasure — sync ledgers (AssetStatusStore, UploadManifest), thumbnail + gallery caches, and the entire UserDefaults domain (chat transcripts, memories, insights, deployment config).
- Sign out — Apple user ID, Cognito identityId and STS credentials removed from the Keychain.
Once step 1 has started, steps 3 and 4 always run: a refused S3 list or delete is recorded and reported, never thrown. The alert then says that part of the backup is left and how to finish (reconnect the stack, sign in with the same Apple ID, Delete Account again). Only the checks before step 1 (signed in, valid credentials) can stop the deletion, and they stop it before anything is deleted.
Before step 1, backups are paused and the running backup (uploads and
ledger writes included) is awaited (SyncEngine.suspendAndDrain), so no
upload can land in S3, or mark a photo uploaded, after the purge. Backups
are allowed again after the sign-out.
Deliberately not done:
- CloudFormation stack deletion — the stack (bucket, identity pool, alarms) belongs to the user and may serve other household members. The UI says so explicitly and points to the CloudFormation console.
- Cognito identity record deletion —
DeleteIdentitiesis an admin API the app’s role doesn’t (and shouldn’t) have. The orphaned identityId maps to nothing once the data is gone, and lives in the user’s own account. - Sign in with Apple server-side token revocation — Apple’s REST revocation API presupposes a developer server storing refresh tokens; we store no Apple tokens off-device. Users can additionally revoke the app under Settings → Apple ID → Sign-In & Security.
- S3 version purge — the bucket keeps deleted-object versions 30 days (ADR safety net). Purging versions requires bucket-admin rights the app role lacks by design. Documented in the privacy policy.
Consequences
- Compliant with 5.1.1(v): initiation and completion fully in-app, no support flow required.
- Deletion also forgets the deployment config — deleting then re-onboarding requires re-pasting the setup code. Acceptable: least surprising reading of “erase everything”.
- The privacy policy (docs/privacy-policy.md) documents the 30-day version window and the user-owned stack.