Decision Log¶
Short architecture decision records. Status: Accepted (in force), Proposed (default we will use unless overruled), Pending (needs the founder's input).
ADR-001 — Own canonical schema, OpenTelemetry-compatible at the edges · Proposed¶
Context. OTel is the standard for distributed tracing, and its GenAI conventions cover models,
tools and agents. But those conventions are still marked Development, have renamed attributes
before, and OTLP exports spans only on completion (crashed or still-running work is invisible).
Decision. Native JSON event schema with span_start/span_end/span_event; trace/span IDs in
W3C/OTel format; a mapping module to and from gen_ai.*; an OTLP ingestion endpoint in Phase 7.
Consequence. One more format to maintain, in exchange for in-flight visibility, explicit
provenance/unknown semantics, and insulation from upstream renames.
ADR-002 — PostgreSQL as the initial store · Proposed¶
Context. Pilot-scale volume (up to tens of millions of spans), strong need for tenant isolation, transactional idempotent merges, and flexible attributes. Decision. PostgreSQL 16 with JSONB attributes, project-first keys, repositories as the only DB access path. Consequence. Simple operations now; columnar analytics store later if measured volume demands.
ADR-003 — Content capture off by default · Proposed¶
Decision. Default metadata_only; redacted and full are explicit opt-ins; project policy on
the server is a ceiling; content stored in its own table.
Consequence. Less debugging detail out of the box; users enable content per environment.
ADR-004 — One run = one trace · Proposed¶
Decision. A run's root span is the trace root; sessions group runs via session_id; detached
background work gets its own trace plus a link.
Consequence. Simple queries and direct OTel mapping. Very long-lived agents (hours) are one large
trace; revisit if that becomes common.
ADR-005 — stdlib-only Python SDK core · Proposed¶
Decision. No third-party runtime dependencies in aobs core; integrations are optional extras.
Consequence. Zero dependency conflicts in user apps; we maintain a small HTTP transport ourselves.
ADR-006 — Explicit wrappers, not global monkey-patching · Proposed¶
Decision. Provider integrations wrap a client instance the user passes in
(wrap(OpenAI())) and only touch documented public methods.
Consequence. One line of user code per client, in exchange for predictable behaviour across
provider SDK upgrades.
ADR-007 — First provider integration: OpenAI-compatible Chat Completions · Proposed¶
Context. It is the most widely implemented request shape (many hosted providers and local servers expose it), so one adapter gives broad early reach and a free local test path. Decision. First adapter targets that shape, plus a deterministic fake model for tests. The second adapter (Phase 7) is deliberately a different shape to prove neutrality.
ADR-008 — Synchronous ingest pipeline, no queue · Proposed¶
Decision. Validate-merge-store inside the request. Introduce a Postgres-backed work queue only when ingest latency measurements require it.
ADR-009 — Order-independent conflict resolution · Accepted (2026-10-12)¶
Context. Retries, parallel exporters and out-of-order delivery mean "the first event received"
is not a meaningful notion, yet the stored trace must not depend on arrival order.
Decision. Identical duplicates are no-ops. When two events of the same type for the same span
(or the same event_id) differ, the one whose canonical JSON (sorted keys, no whitespace) has the
lowest SHA-256 wins, and the conflict is counted. Between a span's start and end, end wins field by
field (deep merge of objects; arrays replaced).
Consequence. Fully deterministic and testable by permutation. The winner of a genuine conflict
is arbitrary-but-stable — acceptable because conflicts indicate a bug upstream and are surfaced.
ADR-010 — Strict validation for known versions, lenient only for newer minors · Accepted (2026-10-12)¶
Context. Rejecting all unknown fields breaks older servers when SDKs upgrade; accepting all of
them hides typos.
Decision. The server knows a maximum minor per major. Payload minor ≤ known: unknown fields are
errors. Payload minor > known: unknown fields are moved into extra (with their paths) and
counted. Unknown major: batch rejected. Fields that future minors may extend (kind,
llm.operation, tool.type, error.origin, link relation, capability names) are open,
pattern-checked vocabularies; closed enums (status, content status, usage source, capture
mode) change only in a major version.
Consequence. Typos fail fast in tests; an old server keeps accepting a new SDK.
ADR-011 — Keyed (HMAC) input fingerprints · Accepted (2026-10-12)¶
Context. Waste and loop detection need to know that two calls had identical inputs without
storing the inputs. Unkeyed hashes of low-entropy inputs are reversible by enumeration.
Decision. input_fingerprint = "hmac-sha256:<key_id>:<hex>" over a canonical JSON encoding of
the input. Key held by the SDK: random per process by default, or a configured stable key for
cross-process/cross-run comparison. Never sent to the server.
Consequence. Default fingerprints only compare within one process (enough for in-run repeats).
Cross-run waste analysis requires users to configure a stable key — documented in Phase 10.
ADR-012 — SDK requires Python ≥ 3.11 · Accepted (2026-10-12)¶
Context. Phase 0 planned 3.10. Python 3.10 reaches end of life in October 2026 (PEP 619), and the
uv workspace resolves one requires-python for all members (the server needs 3.11).
Decision. aobs requires Python ≥ 3.11; CI tests 3.11 and 3.13.
Consequence. Users on 3.10 cannot install the SDK; they are on an unsupported Python anyway.
ADR-013 — Exporters receive the encoded wire batch · Accepted (2026-10-12)¶
Context. The SDK must not be tied to one transport, and tests must see exactly what a collector
would receive.
Decision. Exporter.export(EncodedBatch, timeout_s) -> ExportResult where EncodedBatch.body
is the complete schema-v1.0 envelope bytes, rebuilt per attempt (fresh sent_at, same
batch_id). Outcomes: success, retry (with optional retry_after_s), failure. Exceptions
count as retry.
Consequence. Every transport (HTTP, file, in-memory, future ones) is conformance-testable by
validating body; encoding happens once, on the worker thread.
ADR-014 — Ingest response contract the SDK relies on · Accepted (2026-10-12)¶
Context. The SDK's retry behaviour depends on how the collector answers; Phase 3 must honour it.
Decision. 2xx = batch processed; the body may be a JSON object whose rejected array lists
events rejected individually (counted by the SDK, never retried). 408/429/5xx = retry
(Retry-After in seconds honoured). Other 4xx = permanent failure. 3xx is never followed.
Consequence. Phase 3 adds a contract test running the SDK's HttpExporter against the real API.
ADR-015 — Ingest semantics: per-event results, digest idempotency, per-trace locks · Accepted (2026-10-13)¶
Context. POST /v1/ingest must be safe under SDK retries, out-of-order delivery and
concurrent writers, without a queue (ADR-008) and without claiming exactly-once delivery.
Decision.
- Validation. An invalid envelope rejects the batch (400). Valid events are processed
individually: each gets exactly one of accepted, duplicate, dropped or rejected, still
with 200 (ADR-014).
- One transaction per batch. Any database failure stores nothing (500/503), so the retry
starts clean.
- Idempotency is per event and content-based. An event's key is (trace, span, start|end), or
the event_id. Its content is the SHA-256 of its post-privacy canonical JSON. The existing
*_digests columns record which copies were seen (lowest 16, always including the winner), so
no migration was needed and batch_id is informational only.
- Locking. Every affected trace row is locked with one multi-row
INSERT … ON CONFLICT DO UPDATE … RETURNING, in trace-id order. Inserting first and then
SELECT … FOR UPDATE would lock new and existing rows in different orders and can deadlock
overlapping batches. All writes for a trace happen under its lock, and the summary is recomputed
from stored rows with the shared assembly code.
- Span event ids are bound to the trace and span of their first stored copy; a copy naming
another trace is rejected (event_id_conflict).
Consequence. Resending is harmless and the stored trace is independent of arrival order (tested by permutation and concurrency tests). The summary costs O(spans in trace) per batch, which is acceptable under the 10,000-span cap; it may move to incremental updates if measurements require it. A retry after a project's privacy settings changed is counted as a conflict rather than a duplicate.
ADR-016 — Trace reads: uniform 404, current policy on read, content opt-in · Accepted (2026-10-13)¶
Context. Stored traces must be readable by project without revealing anything about other
projects. Privacy settings can tighten after data was stored, and content is the most sensitive
part of a trace.
Decision.
- Endpoints. GET /v1/projects/{project_id}/traces/{trace_id} and …/events need a
read-scoped key of that project (auth.authorize).
- One 404 for everything. Another project (existing or not), a malformed id, an unknown trace
and a deleted trace all get the identical 404.
- Privacy on read. Every read passes stored events through the project's current privacy
step (M4), so lowering the ceiling also hides older data. Content values and newer-minor fields
(__extra__) are returned only with include_content=true; status markers always.
- Consistent snapshots. Each read runs in one REPEATABLE READ, read-only transaction, so
the summary and the spans agree.
- Pagination. Spans are keyset-paginated by span id over the fully assembled trace (shared
assembly code). Span events are keyset-paginated by (time, event id) with an opaque cursor.
Consequence. - No existence oracle across projects. - Tightening privacy is retroactive for reads, though not for already-stored rows, which a future retention or rewrite job would handle. - Reading a page of a large trace costs one whole-trace assembly; an index-only span listing can replace it if measurements require.
Pending decisions¶
Not yet answered; the defaults are in use as of Phase 2 (2026-10-12). None of them affected Phase 1 or 2 code. P1 and P4 should be settled before the SDK is published to a package index (not done in Phase 2); P2 before Phase 4.
| # | Question | Default if unanswered | Why it matters |
|---|---|---|---|
| P1 | Where should the repository live? (GitHub repo to push to) | Keep building in this session workspace and hand over files | Work in this workspace is not a durable home |
| P2 | First deployment target: self-hosted for one team, or hosted multi-tenant SaaS? | Self-hosted single-organization; org/project model still built in | Decides whether real user login (OIDC/password) is needed by Phase 4 or can wait until Phase 8 |
| P3 | Which LLM provider/API key do you have for the live example? | OpenAI-compatible adapter; tests use the fake model; example can also run against a local OpenAI-compatible server | Determines the first real-provider test |
| P4 | Product/package name | aobs placeholder |
Renaming a published Python package later is disruptive; cheap to settle before Phase 2 |