This contract implements the design part of #217 and is the authority for the Quarkus server (#218), remote Eclipse adapter (#220), web frontend (#221) and change synchronization (#219). All five are required before Timekeeper 2.0. The embedded implementation remains available and its database is not implicitly copied, merged or opened by the server.
openapi.json describes /api/v1 using
OpenAPI 3.1. API version 1 is
independent of the Timekeeper product version. The server must serve this
contract at /api/v1/openapi.json; publishing the contract does not certify an
implemented server. HTTP behavior follows
RFC 9110, bearer authentication
RFC 6750, and error bodies
RFC 9457.
A bearer token maps to one immutable server owner and read/write permissions.
Multiple tokens/devices may belong to the same owner; rotating a token does not
change resource ownership. A valid read-only token can query its owner’s data
but mutations return 403. Missing, invalid, expired or revoked tokens return
401 with WWW-Authenticate: Bearer. No owner is selected from a query, body,
external provider identity, Eclipse workspace or token text supplied as data.
The /auth endpoints provide account management, web sign-in and personal API
tokens; see account operations. There is no cross-owner
sharing endpoint. Administrator roles never grant another owner’s business data. TLS is required outside loopback development.
New personal API tokens are returned once by their creation endpoint; token
secrets never appear in resource DTOs, URLs, logs, errors or change notifications.
Web requests may instead use the HttpOnly Secure cookie; unsafe cookie requests
require the memory-only CSRF value returned by /auth/session.
Stateful clients bind their session to the first authenticated owner. Authorized
responses include X-Timekeeper-Owner, the canonical unpadded base64url encoding
of that owner’s UTF-8 ID, including empty pages and 204 responses. Send that
value on subsequent requests. The header is an expected-owner restriction and
cannot grant access to a different owner. Malformed, duplicate or noncanonical
values return 400. A valid token mapped to a different owner returns 401
OWNER_CHANGED before reading resources or executing a mutation.
Omitting the header retains stateless token-owner behavior for existing API
consumers. Do not reassign issued tokens to other owners. The binding protects
existing sessions if registry configuration nevertheless changes. An existing
client retains its original binding through failures; restoring that owner can
recover the session, while an intentional account switch creates a new session.
The Java HTTP client learns the binding from the response header or resource/
profile ownerId, including a first empty page, and never silently replaces it.
Long polling also rechecks ownership while a request is waiting.
Every project, task, label, activity, external reference, profile, cursor,
command receipt and change event belongs to exactly one authenticated owner.
Ownership is immutable. All repository methods (including relation and external
reference lookup, existence checks, lists, reports and deletes) must operate
within that owner scope. Scope is established by the authenticated server
adapter before invoking DefaultTimekeeperService, using owner-bound repository
ports. The adapter supplies the authenticated owner to activity commands and
queries; the domain/application services remain independent of HTTP, Quarkus,
Eclipse and Mylyn. Server persistence enforces owner-scoped relations and
external-reference uniqueness, version checks and the single-open-interval rule
within the transaction. Merely checking an activity’s ownerId at serialization
or filtering a shared repository after a service operation is insufficient.
A foreign resource or foreign relation is indistinguishable from a missing one:
404 NOT_FOUND, with no resource contents, versions or owner information.
Foreign filters also return 404. Lists, totals, provider lookups and exported
snapshots cannot reveal other owners. A portable import cannot assign an owner.
External identity (providerId, repositoryId, externalId) is unique per owner;
two owners may independently track the same Jira issue with different native
UUIDs. Linked tasks preserve their UUID/history when the provider is unavailable
or the reference is removed. local remains an embedded-only default and is
never a shared remote authentication fallback.
| Workflow | Operations |
|---|---|
| Projects | List/get, create, replace name, delete unused project |
| Tasks/subtasks | List/get/filter, create, replace summary/URL/project/parent, delete unused task |
| Provider integration | Find by provider identity, link/update/unlink reference with task version |
| Labels | List/get, create, replace name/color, delete unused label |
| Manual time | Create/get/edit/delete an interval with summary and assigned labels |
| Tracking | Read current interval, atomically activate/switch task, stop interval |
| Daily/weekly reports | Query intervals and authoritative daily/task totals in an explicit zone |
| Portable interchange | Export domain snapshot; atomic import with fresh UUIDs and relation remapping |
| Preferences | Read owner profile, explicitly set reporting zone |
| Concurrent clients | Owner-scoped committed change feed, resource reload, ETag conflict resolution |
Requests use immutable input values; responses are immutable snapshots with
UUIDs, owner IDs and versions, never persistence entities or lazy relationships.
PUT replaces all editable fields. Nullable relations/end/color/URL must be
explicitly present with null when unset. Omitted required fields and unknown
input properties return 400; clients cannot set IDs, owners, versions or manual
flags through normal CRUD. Input length/range/format failures return 422.
Names are trimmed and must contain a non-whitespace character. Optional URLs
are absolute URIs; labels use null or #RRGGBB. Unknown query parameters return
400 rather than accidentally widening a query. Invalid JSON and malformed
headers/UUID path parameters are 400. Unsupported media types are 415. Request bodies over 20 MiB return 413 before
JSON processing; transfer limits also apply to exported snapshots. Successful
JSON is application/json; errors are application/problem+json with matching
HTTP/body status and stable code. Clients branch on status/code, not prose.
Tasks may be unassigned, but a subtask must have the same non-null project as its
parent. Self-parenting, cycles, and moving a task with children to another project
are conflicts (409). Deleting a project containing tasks, a task with children
or activities, or a label assigned to activities returns 409; no implicit cascade
loses recorded time. All referenced projects/tasks/labels must be owned.
External references may be included atomically at task creation (the optional
externalReferences defaults to an empty list) and are subsequently updated
separately from the editable task fields. A reference conflict at creation must
not leave an orphan task. After an ambiguous provider-linked create, reconcile
through the external-reference lookup instead of creating a duplicate.
An interval end must be at or after its start. A manual interval may be open,
but each owner has at most one open interval across all tasks and clients.
Manual editing sets manual=true and cannot change activity task/owner. Creating,
editing or importing an interval whose start is after the backend’s clock returns
422 VALIDATION, whether the interval is open or closed. A start exactly at the
current instant is allowed. Invalid writes leave records and revisions unchanged. Automatic
start/stop always uses the server clock. Clients do not submit their clocks for
live tracking.
Activities are stored as separate records for each local calendar day in the owner’s configured time zone (the machine’s calendar zone for local Eclipse storage). Ongoing tracking closes the previous day’s record and continues at midnight, retaining the active UUID on the newest record. Startup catches up missed days. Both manual writes and imports use the same day boundaries. Each part retains task, owner, summary, labels and the original manual flag; manual edits still mark their resulting parts as manual. Exact instants and nanoseconds are preserved, including 23/25-hour DST days. Splits occur only at boundaries that have elapsed; a permitted future end is split further as those days arrive. An end exactly at midnight creates no empty completed record.
Create/update responses and Location/ETag identify the last resulting day; earlier parts appear as additional activities and change events. Each daily record can be edited or deleted independently. A pre-midnight editor has a stale version after rollover and must reload. Changing the reporting zone can split existing records at additional boundaries; it never joins independently edited records. Day maintenance and all parts of a write commit atomically under the same per-owner lock, including across server replicas.
Portable interchange exports timekeeper-domain-v3 JSON, including clients, mandatory
project/session client links, session summaries and optional task, direct-project
assignments. Import also accepts older timekeeper-domain-v1 and
timekeeper-domain-v2 snapshots, assigning their projects/sessions to General.
The formats contain no SQL/JPA table names. CSV v3 adds clients.csv and client/summary
columns; v2 includes workSessions.csv. Every declared
file must be present. Older CSV clients reject the new format instead of silently
discarding independent sessions or client metadata.
Export reads a consistent owned snapshot. Import validates the entire graph,
then generates fresh UUIDs and rewrites references in one transaction; it never
updates existing records. Source IDs are correlation keys only. A failure leaves
no partial records, command receipt or events. External identity collisions are
409; invalid graphs are 422. Open imported intervals must respect the existing
owner’s active interval. Import does not implicitly change the configured zone.
The result maps every source ID to its new ID. Transfer limits are 10,000 records
per type and 20 MiB; oversized import/export returns 413 without partial data.
Eclipse CSV and report-template adapters translate domain snapshots locally;
server API operations must not expose or write embedded persistence tables.
This is current-model interchange, not historical migration or database backup.
GET and successful create/update/command responses include a strong opaque
ETag for the canonical snapshot representation, alongside its nonnegative JSON
version. Echo the received tag; do not construct it from the JSON version.
Serialize set-valued references/label IDs deterministically. A tag must change
when either stored state or its wire representation changes (including an API
representation revision). PUT, DELETE,
reference changes, profile updates and stop require exactly one strong If-Match.
A missing header returns 428 PRECONDITION_REQUIRED; weak, wildcard, list,
empty or malformed tags return 400. An owned stale version returns 412
STALE_VERSION without changes. Authorization precedes version checks so a
foreign stale write still returns 404. Versions are safe JSON integers, never
floating-point counters. Each successful change increments its resource version;
delete checks the current version too. A database compare-and-set is required;
a read/check followed by an unconditional write is not sufficient.
Start requires expectedActiveId and expectedActiveVersion: both null when
there was no open interval, otherwise the ID/version last observed by the client.
The server compares and changes active state under an owner-level lock within
one transaction, including the empty-state race. A mismatch returns 409
ACTIVE_STATE_CHANGED and changes nothing. When the current interval already
belongs to the requested task, return it unchanged with 200. Otherwise close the
old interval and create the new one with the same server-clock instant, returning
Start, stop and import require an Idempotency-Key UUID. The namespace is per
owner across these operations. Persist the parsed request fingerprint (method,
canonical path, normalized JSON and original If-Match) and successful HTTP
status/body/ETag/Location with the changes in the same transaction. Authenticated
same-owner retries with the same fingerprint return the original response
without another change/event, including after restart and when the original
If-Match or observed active state has become stale. Reusing a key for different
input returns 409 IDEMPOTENCY_KEY_REUSED without changes. Normalize JSON object
key order and insignificant JSON whitespace; arrays retain order and scalar
values remain exact. A changed scalar or array is different input even if a
domain normalization would ultimately produce the same stored value. Validate the complete request before executing;
failed requests leave no receipt and can be retried after correction. Concurrent
identical requests must share one committed result; an in-memory cache alone is
insufficient. Authorization is rechecked on every retry, including permission
revocation. Receipts survive resource deletion and are retained at least seven
days from successful commit. Clients must not retry an uncertain operation after
that window without reconciling current server state. Canceling an HTTP request
does not prove rollback; retry with the same key after a timeout/503.
The replayed snapshot may be older than the current resource. Clients use a fresh GET/change feed before further edits, and never replace unsaved fields merely because a delayed command response arrives. Standard CRUD POSTs are not implicitly replayable; reconcile ambiguous creates by reloading instead of blindly creating another record. Import provides durable retries for bulk data.
/work-sessions supports owned listing and manual creation; individual records
support read, versioned edit and deletion. /work-sessions/active returns the
running session or 204. /work-sessions/start and /{id}/stop use durable
idempotency keys, and stop also requires the original ETag. Sessions can span
midnight and never start or stop activity timers implicitly. The owner lock
allows only one running session per owner, including across replicas. Several
completed sessions may belong to the same day.
Activities may omit their task and optionally name a direct project. They are independent of sessions. Moving, resizing, stopping or deleting a session never changes activities or task timers; activities may exist outside every session.
/work-sessions/{id}/report derives context from that owner’s overlapping
activities, including running timers, in one consistent snapshot. Tracked time
sums each interval clipped to the session; allocated time is the union of those
portions. Unallocated time subtracts that union from session duration, preserving
nanoseconds without counting overlaps twice. Boundary-crossing activities are
identified; activities merely touching an endpoint do not overlap.
Compatibility: optional activity workSessionId values in create/start and
v1/v2/v3 imports are accepted but ignored, even when that session is absent.
Exports and activity responses omit the retired field. The deprecated
PUT /activities/{id}/work-session rejects non-null attachment requests with 409;
null is an authorized, version-checked detach/no-op. It cannot create a link.
Server startup removes only legacy link metadata under each owner’s database
lock, retaining resource IDs, versions, timestamps, task/project/label references
and active timers. The canonical SQL schema is unchanged. Reads ignore stale
link metadata and session deletion tolerates links written by older replicas;
upgrade all replicas together to obtain consistent temporal-report semantics.
Collection pages use limit (default 100, maximum 500) and an opaque cursor.
Projects/tasks/labels sort by UUID; activities sort by start then UUID. A cursor
binds the authenticated owner, normalized filters, last key and owner revision.
Malformed, foreign or mismatched-filter cursors return 400. A committed owner
change during pagination returns 409 CURSOR_EXPIRED, requiring a new traversal;
there are no silent duplicate/omitted records across revisions. Each page returns
items, nextCursor (null when finished) and revision. A substring q filter
matches project/label name or task summary after trimming, case-insensitively.
Task unassigned=true cannot be combined with a project filter. Filters combine
with AND. Activity label filtering matches membership of that label.
All wire/persistence activity times are UTC RFC 3339 instants ending in Z,
with at most nine fractional digits. Local dates/times are never timestamps.
A profile stores an IANA zone ID (default UTC at provisioning); clients may
explicitly configure it. Remote Eclipse and web reporting use this profile,
rather than silently adopting different host zones. Report override zoneId
is explicit and does not change the profile. Unknown zones return 422.
Ranges are [from,to), with to strictly after from. Queries include intervals
that overlap the range and keep original timestamps in returned snapshots.
Reports clip to the range and split durations by actual local midnight instants;
DST days may have 23 or 25 hours. A week starts Monday. Open intervals use one
returned generatedAt instant for the whole report. Totals sum recorded durations
(including manual overlaps) with nanosecond precision, serialized as ISO 8601
duration strings, not rounded decimal hours. Pagination never truncates totals.
Each committed change receives an increasing owner-local sequence, persisted
atomically with the data. Collection/report revision is the current sequence.
GET /changes?after=<sequence>&waitSeconds=25 provides long polling; duplicate
notifications are harmless and sequence gaps are allowed. Events contain only
entity type, change type, owned ID, sequence and timestamp. At least seven days
are retained; an expired/ahead sequence returns 409 RESYNC_REQUIRED. Empty
responses keep nextSequence=after; process items before advancing the cursor.
A replay/no-op emits no event. Clients refresh affected resources after events,
preserve unsaved edits, and resolve a later stale save through If-Match. Reconnect
resumes from the last processed sequence. Full resynchronization starts at the
current revision, loads consistent pages (restarting on CURSOR_EXPIRED), then
catches up changes since that revision. The version check remains authoritative
when notifications race or a resource was deleted. Offline edits stay pending
and must not silently create an embedded fork or overwrite remote changes.
Within /api/v1, preserve paths, methods, required inputs, existing fields,
meanings, status/code behavior, time precision and ownership rules. Additive
optional response properties and endpoints are allowed; clients ignore unknown
response properties and handle unknown problem codes via HTTP status. Inputs
remain strict; new optional input fields require clients to check server support
before sending them to an older server. New required fields, removals, type or
semantic changes require /api/v2, with a documented deprecation period. The
OpenAPI document’s minor/patch version tracks compatible edits. Domain transfer
format versioning is separate; changing its closed schema requires a new format.
Run static specification/schema tests:
python3 -m venv /tmp/timekeeper-contract-venv
/tmp/timekeeper-contract-venv/bin/pip install -r common/api/tests/requirements.txt
/tmp/timekeeper-contract-venv/bin/python -m unittest discover -s common/api/tests -v
http_acceptance.py is the reusable black-box server
suite. It performs writes and requires an isolated test server/database with
two owners and a read-only token for the first owner. Supply fixture tokens via
TIMEKEEPER_TEST_TOKEN_A, TIMEKEEPER_TEST_TOKEN_B and
TIMEKEEPER_TEST_TOKEN_READ_ONLY; never use real accounts/data. Run:
python common/api/tests/http_acceptance.py --base-url http://127.0.0.1:8080/api/v1
The suite checks actual HTTP authentication/authorization, cross-owner relations, stale writes, atomic switching, duplicate commands, provider identities, DST reports, pagination, imports and owner-scoped notifications. Static schema tests do not prove server enforcement. #218 must run this suite against the real Quarkus server/database and separately verify restart/durable receipts, concurrent requests, schema guards and operational configuration. #220/#221/#219 add actual client behavior, unsaved-edit and reconnect acceptance before the parent closes.
/clients provides owned, paginated CRUD for a name and required #RRGGBB color,
using the same authorization, opaque ETags and change feed as projects. A project
has exactly one clientId, and a client can own many projects. An optional
icon selects a bundled project symbol; omitted icon fields preserve the current
value on edits and explicit null clears it. The published ProjectIcon enum
limits values to known identifiers. Icons remain server metadata, survive legacy
Eclipse writes, and round-trip through account archives and domain JSON/CSV
transfers. Work sessions have exactly one clientId and an optional summary.
An optional, owned projectId derives the session client from the project.
A conflicting explicit client is rejected. Omitted projects are preserved on
edits; explicit null removes the project. Project filters are available on
/work-sessions. A referenced project cannot be deleted or moved to another
client until its sessions have been reassigned, preserving historical totals.
/projects and /work-sessions accept a clientId list filter. A referenced
client must belong to the authenticated owner. Deleting a referenced client is
409, with the whole transaction rolled back.
Client associations are server metadata stored in the canonical resource payload and owned foreign-key relations. The common interval/project models and Eclipse persistence/UI do not need client fields. Web writes supply clientId and an optional summary (up to 4,000 characters). Omitted clients use General on creation and preserve the existing client on edits; explicit null clients are rejected. The separate title field is retired. Startup moves existing titles into summary, preserving distinct notes and avoiding duplicate prefixes. Legacy title inputs and archives remain accepted and are folded into summary; new responses omit title. A server stop preserves the summary and client. Each mutation commits metadata and its domain changes under one owner lock, version and change event, and responses/ETags cover the complete stored representation.
Startup backfills existing records for all stored owners, including owners no longer in the token file. The backfill creates one owned General client, preserves record IDs and exact intervals, increments changed resource versions and emits change events. Repeated startup is idempotent. The generic SQL table schema is unchanged. Back up the server data before upgrading, and upgrade all replicas together to avoid older binaries dropping the new metadata.
The /imports endpoints accept normalized completed Detailed Report entries,
with explicit source-zone conversion and reviewed catalog mappings. CSV parsing
is separate from server authority. Preview writes no import or business records;
creating a job stores its immutable mappings, file/source fingerprints, account
report zone and selected count/time. Only authenticated ownership determines the
destination account. Source member/workspace identity never creates accounts or
shared workspaces. Work sessions are not inferred.
Commit sequential batches of at most 100 entries and 1,000 resulting local-day activities. Each batch atomically commits domain changes, all daily IDs, provenance, catalog decisions and its durable outcome. Repeating the same job, index and input returns the original outcome; conflicting input is 409. After an uncertain reply, GET the job before continuing: a cached historical outcome can predate Undo. Completed jobs reconcile exact imported plus skipped source nanoseconds and selected entry counts. Changing the account reporting zone requires a new review/job; the importer does not change that preference.
Source IDs take priority over fingerprints of exact UTC instants, original client/project/task names, descriptions and sorted tags. Filename, header variant and chosen destination IDs are excluded. Later exports reuse the original active claim, report edits without overwriting them, and retain the original creator. Unclaimed exact matches require explicit distinct-create or version-checked reuse. Indistinguishable source rows use reviewed occurrence numbers.
Whole-job Undo first fences new import batches, including a partially completed job. Subsequent previews and commits each handle at most 100 records. Unchanged created activities can be removed; edited records require explicit KEEP or DELETE. Running timers, references from another active import and catalog dependencies require keeping the record or resolving the blocker first. Reused records are preserved. Children commit before their catalog parents are assessed. Every commit rechecks its preview token under the owner lock, with durable receipts and cursors for restart or lost replies. Automatic daily repartitioning tracks all derived IDs; manual edits stay visible for review. Missing records are reconciled without deleting anything else, and removing all original parts releases only that original source claim, allowing deliberate re-import.
Private jobs, receipts, audit items, mapping plans and lineage use internal owned resource types; public domain collections and portable exports omit them. Protected database backups retain this authority. No database schema change is required. Domain writes and deletions emit the normal owner-scoped change feed.
/api/v1/admin/settings returns redacted saved/effective configuration and an
ETag. PUT requires an actual administrator account session, CSRF and current
password proof; token-derived sessions cannot save. Shared desired settings take
effect at coordinated startup, with a monotonically newer origin epoch and a
local-version fence for running replicas. Schema 5 stores the global singleton
separately from owned work data; SMTP secrets are never part of API reads or
personal portability archives. HostPolicy applies the exact effective hostname
and loopback to every HTTP route, including static resources and health checks,
without forwarded-host trust. The framework localhost default is replaced by
this dynamic allowlist, rather than pinning the hostname in generated files.
Clients may opt into Undo on conditional work-session PUT/DELETE requests with
X-Timekeeper-Undo: true. Successful responses include X-Timekeeper-Undo-Id
and X-Timekeeper-Undo-Seconds (600). Existing clients remain unchanged.
POST /undo/{id} accepts {} and requires an Idempotency-Key and write access.
Snapshots and command receipts are private to the authenticated owner.
Undo restores the original UUID, timestamps and metadata at a newer version,
rebuilding validated client/project/label references. It leaves activities
unchanged. It refuses a changed destination/session or a conflicting running
session with UNDO_CONFLICT (409); expired offers return UNDO_EXPIRED (410),
and missing offers return 404. No partial restore is committed. Repeating a
successful Undo returns its recorded result without writing again. At most 50
recent offers are retained per account; expired offers are pruned on subsequent
opt-in mutations. Undo snapshots are omitted from portable account exports.