diff --git a/AGENTS.md b/AGENTS.md index 393aed77d..2a32b79da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,7 +217,9 @@ settings on the signed-out login shell. v2.7.1). TEPP and period-report kinds 422 before any snapshot write. `POST /api/analysis-runs/{id}/start` reconstructs a Pending lineage cutoff bag through `reconstruct()` / `lineage_edge_specs` (ADR 0021 / -v0.88.0). Do not invent a theta. +v0.88.0). Status events stamp `occurred_at` and `recorded_at` from +one PostgreSQL `clock_timestamp()` (ADR 0171 / v2.12.19). Do not +bind Python `datetime.now` as occurrence. Do not invent a theta. Opening a cutoff-rewritten title shows **Body this run knew** from `source_post_revision` beside the live rewrite (ADR 0025 / v2.1.0). Do not invent the earlier sentence when no revision covers the cutoff. diff --git a/CHANGELOG.d/2.12.19-analysis-run-status-same-clock.md b/CHANGELOG.d/2.12.19-analysis-run-status-same-clock.md new file mode 100644 index 000000000..4e1b67a28 --- /dev/null +++ b/CHANGELOG.d/2.12.19-analysis-run-status-same-clock.md @@ -0,0 +1,9 @@ +## 2.12.19 — Analysis-run status same PostgreSQL clock + +- Status events now stamp `occurred_at` and `recorded_at` from one + PostgreSQL `clock_timestamp()` (ADR 0171). The transition trigger + still records database time and raises `recorded_at` when a caller + clock is already ahead, so `analysis_run_status_time_check` no + longer rejects Start on a 15–20 ms Python-vs-PostgreSQL skew. + After `make seed`, Open the Demo Corp lineage run and Start still + recovers the designed A-100 fork. Never invent a theta. diff --git a/CHANGELOG.md b/CHANGELOG.md index 6959d29b5..b4486717b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,19 @@ All notable changes to this project are documented here. Format follows shell. The production frontend build type-checks again. +## [2.12.19] - 2026-08-24 + +### Fixed + +- Starting a Pending lineage reconstruction or TEPP measurement no + longer fails on `analysis_run_status_time_check` when the process + clock is 15–20 ms ahead of PostgreSQL (ADR 0171). Status events + stamp `occurred_at` and `recorded_at` from one `clock_timestamp()`, + and the transition trigger raises `recorded_at` if a caller + occurrence is already ahead. After `make seed`, Open the Demo Corp + lineage run and Start still recovers the designed A-100 fork. + Never invent a theta. + ## [2.12.6] - 2026-08-20 ### Added @@ -269,10 +282,10 @@ All notable changes to this project are documented here. Format follows concurrency/load artifact) and entirely pre-existing (verified via `git diff` that no file in this change touches `analysis_run_start.py` or the 0018 migration that defines this - constraint). Root cause not yet conclusively identified; deferred - as out of scope for this migration-catchup change (a different - feature area -- analysis-run/TEPP lifecycle, not R&R/summary/ - verification) rather than rushed. 553 other tests unaffected. + constraint). Root cause identified in ADR 0171 / v2.12.19: the + supplied occurrence can sit ahead of the trigger write clock, so + `recorded_at` must share that clock or be raised to `occurred_at`. + Fixed in [2.12.19](#21219---2026-08-24). 553 other tests unaffected. - `get_or_create_corporate_entity`'s post-lock duplicate-create re-check fuzzy-matched against every cataloged entity, not just an exact diff --git a/CLAUDE.md b/CLAUDE.md index 1bcf50763..c3613967c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,7 +41,8 @@ are 422. The Request button waits until affiliated corps load; choose a corp if the token walks more than one. `POST /api/analysis-runs/{id}/start` commits Running plus a durable outbox row, then reconstructs that frozen cutoff bag (ADR 0021 / ADR 0023) or submits TEPP through -`tepp_client` (ADR 0022). A missing transport or unused accepted +`tepp_client` (ADR 0022). Status events share one PostgreSQL +`clock_timestamp()` for `occurred_at` and `recorded_at` (ADR 0171). A missing transport or unused accepted envelope is Failed. Failed TEPP is terminal — connect a TEPP transport from that Failed row. Create does not invent a Pending TEPP row. Do not invent a theta. Hover the Result prefix to read diff --git a/backend/app/analysis_run_ingestion.py b/backend/app/analysis_run_ingestion.py index fb76ad821..1817b0b16 100644 --- a/backend/app/analysis_run_ingestion.py +++ b/backend/app/analysis_run_ingestion.py @@ -947,8 +947,10 @@ async def create_pending_analysis_run( await conn.execute( """ insert into analysis_run_status_event - (analysis_run_id, status_ordinal, status_code, occurred_at) - values ($1, 1, 'analysis_status_pending', clock_timestamp()) + (analysis_run_id, status_ordinal, status_code, + occurred_at, recorded_at) + select $1, 1, 'analysis_status_pending', write_clock, write_clock + from (select clock_timestamp() as write_clock) same_clock """, run_id, ) diff --git a/backend/app/analysis_run_start.py b/backend/app/analysis_run_start.py index dce93fa55..5deb8347c 100644 --- a/backend/app/analysis_run_start.py +++ b/backend/app/analysis_run_start.py @@ -326,15 +326,21 @@ async def _append_status( analysis_run_id: str, status_ordinal: int, status_code: str, - occurred_at: datetime, failure_code: str | None = None, ) -> None: - """Append one legal lifecycle event. Failed rows carry a machine code.""" + """Append one legal lifecycle event. Failed rows carry a machine code. + + Occurrence and recording share one PostgreSQL ``clock_timestamp()`` + so ``analysis_run_status_time_check`` cannot see a Python clock that + is ahead of the trigger write clock (ADR 0171). + """ await conn.execute( """ insert into analysis_run_status_event - (analysis_run_id, status_ordinal, status_code, occurred_at, failure_code) - values ($1, $2, $3, clock_timestamp(), $4) + (analysis_run_id, status_ordinal, status_code, + occurred_at, recorded_at, failure_code) + select $1, $2, $3, write_clock, write_clock, $4 + from (select clock_timestamp() as write_clock) same_clock """, analysis_run_id, status_ordinal, @@ -570,7 +576,6 @@ async def enqueue_pending_analysis_run( analysis_run_id, await _next_status_ordinal(conn, analysis_run_id), _RUNNING, - now, ) await conn.execute( """ @@ -789,7 +794,6 @@ async def _deliver_lineage_reconstruction( analysis_run_id, await _next_status_ordinal(conn, analysis_run_id), _SUCCEEDED, - finished, ) @@ -801,7 +805,6 @@ async def _deliver_tepp_measurement( tepp_client: TeppClient, ) -> None: """Submit the frozen snapshot through ``tepp_client``. Never persist a theta.""" - now = datetime.now(timezone.utc) request = tepp_run_request( idempotency_key=str(locked["idempotency_key"]), snapshot_sha256=str(locked["snapshot_sha256"]), @@ -817,14 +820,10 @@ async def _deliver_tepp_measurement( ): status_code = _FAILED failure_code = "tepp_result_not_persisted" - finished = datetime.now(timezone.utc) - if finished < now: - finished = now await _append_status( conn, analysis_run_id, await _next_status_ordinal(conn, analysis_run_id), status_code, - finished, failure_code, ) diff --git a/docker/postgres-init/migrate.sh b/docker/postgres-init/migrate.sh index 7e065ce27..054671044 100644 --- a/docker/postgres-init/migrate.sh +++ b/docker/postgres-init/migrate.sh @@ -18,7 +18,7 @@ for migration in /opt/lineageweave/migrations/*.sql; do migration_name=${migration##*/} case "$migration_name" in 0012_*|0013_*|0014_*|0015_*|0016_*|0017_*|0018_*|0019_*|0020_*|0021_*|0022_*|0023_*|0024_*|0025_*|0026_*|0027_*|0028_*|0029_*|0030_*|0031_*|0032_*|0033_*|0034_*|0035_*|0036_*|0037_*|0038_*|0039_*|0040_*|0041_*|0042_*|0043_*|0044_*|0045_*|0046_*|0047_*|0048_*|0049_*|0050_*) ;; - 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0176_*) ;; + 0060_*|0100_*|0101_*|0102_*|0103_*|0104_*|0105_*|0106_*|0107_*|0108_*|0109_*|0110_*|0111_*|0112_*|0113_*|0114_*|0130_*|0133_*|0134_*|0136_*|0137_*|0138_*|0139_*|0173_*|0176_*) ;; *) continue ;; esac printf 'Applying %s\n' "$migration_name" diff --git a/docs/adr/0013-normalized-analysis-run-registry.md b/docs/adr/0013-normalized-analysis-run-registry.md index 15fd040d6..66ac8f032 100644 --- a/docs/adr/0013-normalized-analysis-run-registry.md +++ b/docs/adr/0013-normalized-analysis-run-registry.md @@ -136,7 +136,9 @@ succeeded | failed | cancelled -> terminal The first event must be `pending`, requires an immutable scope, and cannot predate the run request. Failed events require a lowercase machine-code identifier; raw exception text is prohibited. `recorded_at` is overwritten with database system -time on every insert and cannot precede `occurred_at`. +time on every insert and cannot precede `occurred_at`. When a caller supplies +an occurrence already ahead of that write clock, `recorded_at` is raised to +`occurred_at` so the check holds without rewriting occurrence (ADR 0171). `analysis_run_current_status` is a view, not a second mutable state authority. ### Authorization scope diff --git a/docs/adr/0171-analysis-run-status-same-clock.md b/docs/adr/0171-analysis-run-status-same-clock.md new file mode 100644 index 000000000..2ffc8db83 --- /dev/null +++ b/docs/adr/0171-analysis-run-status-same-clock.md @@ -0,0 +1,68 @@ +# ADR 0171 — Analysis-run status events share one PostgreSQL write clock + +**Decision status:** Accepted +**Date:** 2026-08-24 + +Amends [ADR 0013](0013-normalized-analysis-run-registry.md). Independent of +leftover-map persist, leftover UI, leftover two-axis distance, TEPP +arithmetic, and Valkey outbox payload shape. + +## Context + +ADR 0013 distinguishes lifecycle `occurred_at` from durable +`recorded_at` and requires `occurred_at <= recorded_at`. The 0018 +trigger overwrote `recorded_at` with `clock_timestamp()` after a +`FOR UPDATE`. Callers that bound Python `datetime.now(timezone.utc)` +as `occurred_at` reproducibly landed 15–20 ms *after* that write +clock, so `analysis_run_status_time_check` rejected the row +(`CheckViolationError`). Live +`test_start_analysis_run_recovers_the_a100_fork` and +`test_tepp_start_persists_published_accepted_evidence` then could not +Start a Pending lineage or TEPP run against a real PostgreSQL. + +The product start path later switched `occurred_at` to +`clock_timestamp()` while still omitting `recorded_at` (DEFAULT plus +trigger). That still leaves a two-clock window: VALUES vs DEFAULT vs +trigger each call `clock_timestamp()` separately, and any remaining +Python-ahead caller (seed, live test helper, or a future bind) fails +the same check. + +## Decision + +1. Application inserts of `analysis_run_status_event` stamp + `occurred_at` and `recorded_at` from **one** PostgreSQL + `clock_timestamp()` (a `SELECT ... FROM (SELECT clock_timestamp() + AS write_clock)` row). Do not bind Python `datetime.now` as + occurrence. +2. `enforce_analysis_run_status_transition` still overwrites + `recorded_at` with database `clock_timestamp()`. If the supplied + `occurred_at` is already ahead of that clock, raise `recorded_at` + to `occurred_at` so the check holds. Do not rewrite occurrence: + monotonicity and "cannot predate the request" stay on the + caller-supplied instant. +3. Migration 0173 is the single source of the trigger replacement; shipped + migration 0018 remains immutable. The Compose migration service applies + 0173 after the initial schema on fresh installs and replays it on existing + volumes. + +Do not invent a leftover score. Do not invent a theta. + +## Consequences + +Starting a Pending lineage reconstruction or TEPP measurement no +longer fails closed on a 15–20 ms Python-vs-PostgreSQL skew. After +`make seed`, Open the Demo Corp lineage run and Start still recovers +the designed A-100 fork. A synthetic insert whose `occurred_at` is +50 ms ahead of `clock_timestamp()` persists with +`recorded_at >= occurred_at`. + +## References + +Allen, J. F. (1983). Maintaining knowledge about temporal intervals. +*Communications of the ACM, 26*(11), 832–843. +https://doi.org/10.1145/182.358434 + +Lebo, T., Sahoo, S., McGuinness, D., Belhajjame, K., Cheney, J., +Corsar, D., Garijo, D., Soiland-Reyes, S., Zednik, S., & Zhao, J. +(2013). *PROV-O: The PROV ontology* (W3C Recommendation). World Wide +Web Consortium. https://www.w3.org/TR/prov-o/ diff --git a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md index 99dfff47b..4393edd87 100644 --- a/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md +++ b/docs/doctoring/ANALYSIS_RUN_REGISTRY_REFERENCES.md @@ -29,7 +29,9 @@ general-purpose bitemporal database: - `knowledge_cutoff` answers what a specific analysis was allowed to know; - `requested_at` answers when that analysis was requested; - `occurred_at` and `recorded_at` distinguish lifecycle occurrence from durable - database recording. + database recording. Product inserts stamp both from one PostgreSQL + `clock_timestamp()`; the transition trigger raises `recorded_at` when a + caller occurrence is already ahead (ADR 0171). The database requires the aggregate leakage boundary: diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bfde3b7b0..27d575190 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -46,6 +46,7 @@ describe("App, unauthenticated", () => { }); it("shows a login button that starts the real OIDC redirect", async () => { + window.sessionStorage.clear(); render(); const button = screen.getByRole("button", { name: /log in/i }); await userEvent.click(button); @@ -55,6 +56,7 @@ describe("App, unauthenticated", () => { state: expect.objectContaining({ returnUrl: expect.stringMatching(/^\//) }), }), ); + expect(window.sessionStorage.getItem("lineageweave.oidc.returnUrl")).toBe("/"); }); it("remembers a same-origin post deep link before the OIDC redirect", async () => { diff --git a/migrations/0173_analysis_run_status_same_clock.sql b/migrations/0173_analysis_run_status_same_clock.sql new file mode 100644 index 000000000..139679f30 --- /dev/null +++ b/migrations/0173_analysis_run_status_same_clock.sql @@ -0,0 +1,99 @@ +-- Analysis-run status events share one PostgreSQL write clock (ADR 0171). +-- +-- Replaces enforce_analysis_run_status_transition so recorded_at cannot +-- precede occurred_at when a caller supplies a clock that is slightly +-- ahead of PostgreSQL (the live CheckViolationError on +-- analysis_run_status_time_check). Occurrence is not rewritten; the +-- durable write clock is raised to the supplied occurrence when needed. +-- Idempotent migration 0173: create or replace. Does not invent a theta. + +create or replace function enforce_analysis_run_status_transition() +returns trigger +language plpgsql +as $$ +declare + previous_ordinal integer; + previous_status_code text; + previous_occurred_at timestamptz; + run_requested_at timestamptz; + write_clock timestamptz; +begin + -- The immutable parent row is a per-run serialization lock. It prevents + -- concurrent writers from both accepting the same next ordinal. + select requested_at + into run_requested_at + from analysis_run + where analysis_run_id = new.analysis_run_id + for update; + + if not found then + raise exception 'analysis_run_not_found'; + end if; + if not exists ( + select 1 from analysis_run_scope + where analysis_run_id = new.analysis_run_id + ) then + raise exception 'analysis_run_scope_required'; + end if; + if new.occurred_at < run_requested_at then + raise exception 'analysis_run_status_before_request'; + end if; + -- One database clock for the durable write. If the supplied occurrence + -- is already ahead of that clock (Python datetime.now skew), raise + -- recorded_at to occurred_at so analysis_run_status_time_check holds + -- without rewriting occurrence or breaking monotonicity. + write_clock := clock_timestamp(); + new.recorded_at := write_clock; + if new.recorded_at < new.occurred_at then + new.recorded_at := new.occurred_at; + end if; + + select status_ordinal, status_code, occurred_at + into previous_ordinal, previous_status_code, previous_occurred_at + from analysis_run_status_event + where analysis_run_id = new.analysis_run_id + order by status_ordinal desc + limit 1; + + if previous_ordinal is null then + if new.status_ordinal <> 1 + or new.status_code <> 'analysis_status_pending' then + raise exception 'analysis_run_first_status_must_be_pending'; + end if; + return new; + end if; + + if new.status_ordinal <> previous_ordinal + 1 then + raise exception 'analysis_run_status_ordinal_not_contiguous'; + end if; + if new.occurred_at < previous_occurred_at then + raise exception 'analysis_run_status_time_not_monotonic'; + end if; + + if previous_status_code = 'analysis_status_pending' then + if new.status_code not in ( + 'analysis_status_running', + 'analysis_status_cancelled' + ) then + raise exception 'analysis_run_status_transition_invalid'; + end if; + elsif previous_status_code = 'analysis_status_running' then + if new.status_code not in ( + 'analysis_status_succeeded', + 'analysis_status_failed', + 'analysis_status_cancelled' + ) then + raise exception 'analysis_run_status_transition_invalid'; + end if; + else + raise exception 'analysis_run_terminal_status_has_no_successor'; + end if; + + return new; +end +$$; + +comment on function enforce_analysis_run_status_transition() is + 'Serializes status appends and requires immutable scope, request-time ' + 'ordering, database-recorded time that cannot precede occurrence, ' + 'legal transitions, and terminal finality.'; diff --git a/tests/test_analysis_run_registry_schema.py b/tests/test_analysis_run_registry_schema.py index 4161f2452..4f86c4ed9 100644 --- a/tests/test_analysis_run_registry_schema.py +++ b/tests/test_analysis_run_registry_schema.py @@ -19,6 +19,9 @@ _REGISTRY_MIGRATION = _ROOT / "migrations" / "0018_analysis_run_registry.sql" _REGISTRY_ROLLBACK = _ROOT / "migrations" / "rollback" / "0018_analysis_run_registry.sql" _RETENTION_MIGRATION = _ROOT / "migrations" / "0020_analysis_run_retention_purge.sql" +_SAME_CLOCK_MIGRATION = ( + _ROOT / "migrations" / "0173_analysis_run_status_same_clock.sql" +) _RETENTION_ROLLBACK = ( _ROOT / "migrations" / "rollback" / "0020_analysis_run_retention_purge.sql" ) @@ -104,6 +107,7 @@ def registry_db(): cursor.execute(_INITIAL_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_REGISTRY_MIGRATION.read_text(encoding="utf-8")) cursor.execute(_RETENTION_MIGRATION.read_text(encoding="utf-8")) + cursor.execute(_SAME_CLOCK_MIGRATION.read_text(encoding="utf-8")) yield connection finally: connection.close() @@ -755,6 +759,53 @@ def test_status_requires_scope_and_cannot_predate_request(registry_db) -> None: assert recorded_at.year < 2099 +def test_status_same_clock_migration_is_idempotent_and_allowlisted() -> None: + """0173 replaces the trigger in place; the shipped 0018 stays untouched.""" + migration = (_ROOT / "migrations" / "0173_analysis_run_status_same_clock.sql").read_text( + encoding="utf-8" + ) + registry = _REGISTRY_MIGRATION.read_text(encoding="utf-8") + migrate = (_ROOT / "docker/postgres-init/migrate.sh").read_text(encoding="utf-8") + assert "create or replace function enforce_analysis_run_status_transition" in migration + assert "if new.recorded_at < new.occurred_at then" in migration + assert "new.recorded_at := new.occurred_at" in migration + # 0018 is already shipped -- migration-immutability means it never gains + # this fix directly; 0173's CREATE OR REPLACE is the only source of it, + # on both fresh installs and existing volumes. + assert "if new.recorded_at < new.occurred_at then" not in registry + assert "0173_*" in migrate + assert "Do not invent a theta" in migration or "invent a theta" in migration + + +def test_status_accepts_occurrence_ahead_of_the_write_clock(registry_db) -> None: + """A 50ms-ahead occurrence must persist; recorded_at cannot precede it.""" + with registry_db.cursor() as cursor: + snapshot_id = _insert_snapshot(cursor) + account_id = _insert_account(cursor) + run_id = _insert_run( + cursor, + snapshot_id=snapshot_id, + account_id=account_id, + idempotency_key="ahead-clock", + ) + cursor.execute( + "insert into analysis_run_scope " + "(analysis_run_id, scope_kind_code) " + "values (%s, 'analysis_scope_all_visible')", + (run_id,), + ) + cursor.execute( + "insert into analysis_run_status_event " + "(analysis_run_id, status_ordinal, status_code, occurred_at) " + "values (%s, 1, 'analysis_status_pending', " + "clock_timestamp() + interval '50 milliseconds') " + "returning occurred_at, recorded_at", + (run_id,), + ) + occurred_at, recorded_at = cursor.fetchone() + assert recorded_at >= occurred_at + + def test_machine_codes_and_canonical_idempotency_are_fail_closed(registry_db) -> None: """Audit identifiers are canonical and failure details stay machine-safe.""" diff --git a/tests/test_analysis_run_start.py b/tests/test_analysis_run_start.py index c2b1b6065..dc0274dcd 100644 --- a/tests/test_analysis_run_start.py +++ b/tests/test_analysis_run_start.py @@ -1,12 +1,17 @@ """Start-reconstruction contracts: digest, freeze, 422/409, designed tree.""" +import inspect from datetime import datetime, timezone import pytest -from backend.app.analysis_run_ingestion import reconstructed_edge_is_visible +from backend.app.analysis_run_ingestion import ( + create_pending_analysis_run, + reconstructed_edge_is_visible, +) from backend.app.analysis_run_start import ( AnalysisRunStartError, + _append_status, configured_tepp_client, reconstruction_member_ids, reconstruction_result_digest, @@ -171,3 +176,20 @@ def test_running_restart_conflicts_and_succeeded_replay_is_documented() -> None: ) assert running.status_code == 409 assert "Pending" in running.detail + + +def test_append_status_stamps_occurred_and_recorded_from_one_clock() -> None: + """Start must not bind a Python clock that can sit ahead of PostgreSQL.""" + source = inspect.getsource(_append_status) + assert "from (select clock_timestamp() as write_clock) same_clock" in source + assert "write_clock, write_clock" in source + assert "datetime.now" not in source + assert "occurred_at: datetime" not in source + + +def test_pending_create_stamps_occurred_and_recorded_from_one_clock() -> None: + """Create Pending uses the same one-clock insert as Start.""" + source = inspect.getsource(create_pending_analysis_run) + assert "from (select clock_timestamp() as write_clock) same_clock" in source + assert "analysis_status_pending" in source + assert "write_clock, write_clock" in source diff --git a/tests/test_migration_replay.py b/tests/test_migration_replay.py index 3de708868..b0a2f4e26 100644 --- a/tests/test_migration_replay.py +++ b/tests/test_migration_replay.py @@ -121,6 +121,19 @@ def test_migrate_sh_replays_tenant_identity_metadata_migration_on_existing_volum assert "tenant_settings_copyright_year_range_check" in migration +def test_migrate_sh_replays_analysis_run_status_same_clock_on_existing_volumes() -> None: + """Existing volumes must replace the analysis-run status trigger from one clock.""" + root = Path(__file__).resolve().parents[1] + script = (root / "docker" / "postgres-init" / "migrate.sh").read_text(encoding="utf-8") + migration = (root / "migrations" / "0173_analysis_run_status_same_clock.sql").read_text( + encoding="utf-8" + ) + + assert "0173_*" in script + assert "create or replace function enforce_analysis_run_status_transition" in migration + assert "write_clock" in migration + + def test_migrate_sh_replays_catalog_unresolved_reason_migration_on_existing_volumes() -> None: """Existing Compose volumes must receive the new unresolved-reason column.""" root = Path(__file__).resolve().parents[1]