Platform3 · Canonical architecture

Qti 1edtech Architecture

This page is rendered directly from the committed architecture decisions. Each stable section anchor is part of the public traceability contract.

Source-bound decision record

28 committed decisions · qti/1edtech

#qti_auth_shape

qti auth shape

Unspecified

Decision

SHIP HS256 Bearer JWTs with subject, tenantId or tenant_id, role/roles, and optional scopes for protected routes. POST /dev/mint is unauthenticated but restricted to the public demo tenant; GET /fixtures/qti-package.zip and GET /fixtures/qti-package.xml are unauthenticated because they return only tenant-neutral sample fixture bytes with no repository, learner, token, or tenant data. OneRoster-style agentOf, schoolSourcedIds, and classSourcedIds claims are outside the QTI 1EdTech surface.

Alternatives and tradeoffs

  • role-only tokens
  • scoped OneRoster school/class claims
  • API key only

Rationale and consequences

role plus optional scopes is enough for service-role learner-runtime deletion without importing school/class authorization primitives into QTI content and runtime persistence; authenticating static fixture downloads would block the cold demo without protecting tenant state

#qti_authoring_json_read_contract

qti authoring json read contract

Unspecified

Decision

SHIP GET /tenants/{tenantId}/qti/artifacts/{artifactId}/authoring-json as the latest lossless authoring projection read for a known logical artifact, returning an ETag suitable for the subsequent If-Match authoring save.

Alternatives and tradeoffs

  • tell clients to edit delivery-json
  • require clients to keep a private editor cache as source of truth
  • generic artifact detail endpoint

Rationale and consequences

delivery-json may be declared-lossy, so a lossless read-before-write route prevents silent spec loss and makes the authoring workflow buildable from the public API alone

#qti_candidate_runtime_data_read_contract

qti candidate runtime data read contract

Unspecified

Decision

SHIP GET /tenants/{tenantId}/qti/candidates/{candidateRef}/runtime-data as a permission-gated read of one pseudonymous candidate runtime-data resource: delivery sessions, attempts, outcome state, and privacy-redacted processing trace for that tenant and candidateRef.

Alternatives and tradeoffs

  • delete-only runtime-data resource
  • collection-wide attempt list
  • embed candidate runtime data in QTI XML
  • admin database access outside the API

Rationale and consequences

a Pearson-shaped assessment clone needs to review one candidate runtime record before cleanup, while candidateRef scoping avoids broad learner-data browsing and direct PII exposure

#qti_concurrency_model

qti concurrency model

Unspecified

Decision

SHIP narrow If-Match concurrency on PUT /tenants/{tenantId}/qti/artifacts/{artifactId}/authoring-json only. GET delivery-json, GET authoring-json, and GET XML may return projection/version ETags; missing If-Match on save returns 428 and stale or conflicting version state returns 409. Strong ETags on generic resource detail routes are deferred with those generic routes.

Alternatives and tradeoffs

  • last-write-wins
  • strong ETags on every generic detail GET
  • If-Unmodified-Since
  • precondition-failed stale-version response for this surface

Rationale and consequences

authoring edits need conflict detection and a read-before-write ETag, but the surface still avoids generic resource-detail concurrency semantics

#qti_conformance_evidence

qti conformance evidence

Unspecified

Decision

SHIP spec-runner evidence against vendored vendor/qti-spec-bundle/ persisted as qti.conformance_run and qti.conformance_assertion; DEFER external 1EdTech certification.

Alternatives and tradeoffs

  • external 1EdTech conformance certification now
  • no conformance evidence
  • internal-only conformance log

Rationale and consequences

vendored-bundle evidence gives integrators a reproducible public basis for QTI fidelity; external certification is useful only when a contract or compliance commitment requires it

#qti_delivery_session_contract

qti delivery session contract

Unspecified

Decision

POST /tenants/{tenantId}/qti/delivery-sessions with artifactVersionId and candidateRef creates a session; POST /tenants/{tenantId}/qti/delivery-sessions/{deliverySessionId}/attempts records candidate responses against that session; GET /tenants/{tenantId}/qti/candidates/{candidateRef}/runtime-data reads the authorized session/attempt resource for one pseudonymous candidate. When the pinned content declares qti-time-limits, the delivery session is the server-authoritative clock: it records window_started_at and effective_max_time_seconds at start, the delivery JSON snapshot surfaces the timeLimits block, and submitAttempt enforces the window server-side per ITD-027 (the server clock decides in-window vs late; client-supplied timing is never authoritative).

Alternatives and tradeoffs

  • session-less attempts
  • session implied by artifact-version read
  • candidate identity stored directly in QTI XML
  • collection-wide attempt browsing
  • client-authoritative countdown with locally-computed mastery

Rationale and consequences

QTI runtime needs sessions, attempts, scoring evidence, review, and learner-data deletion tied to immutable content and pseudonymous candidate context; timed/fluency delivery additionally needs the server as clock authority and server-side execution of declared outcome processing so mastery is trustworthy evidence, not a client-countdown artifact (ITD-027)

#qti_demo_fixture_helpers

qti demo fixture helpers

Unspecified

Decision

SHIP GET /fixtures/qti-package.zip and GET /fixtures/qti-package.xml as public same-deployment demo-support helpers. They return deterministic QTI 3.0 sample package/XML bytes only, require no authentication, accept optional runId solely to vary demo fixture identifiers and hashes for repeated public-demo runs, carry no tenant route segment, read no tenant-owned repository rows, expose no learner/candidate/auth data, use Cache-Control: public, max-age=3600, and inherit route-scoped CORS for GET, OPTIONS only.

Alternatives and tradeoffs

  • require integrators to hand-assemble ZIP and XML fixtures from docs
  • serve fixtures only from source-code files outside the public API
  • require Bearer auth for static demo fixture downloads
  • store demo fixture packages as tenant-owned repository rows

Rationale and consequences

same-deployment public fixtures make the cold quickstart runnable from the public docs alone while avoiding a new tenant data, repository browsing, or credential primitive

#qti_demo_token_authority

qti demo token authority

Unspecified

Decision

POST /dev/mint issues demo tokens scoped only to the public demo tenant; production tenants require operator-minted JWTs with role and optional scopes.

Alternatives and tradeoffs

  • no demo tokens
  • demo tokens with production authority
  • separate demo deployment

Rationale and consequences

integrators need to call the API cold without an onboarding ticket, while a single tenant-scoped deployment avoids the cold-start state-loss bugs caused by split demo/prod deployments

#qti_error_envelope

qti error envelope

Unspecified

Decision

SHIP the inherited Platform typed RFC 7807 Problem body with stable https://platform.timeback.com/problems/* type URIs, title, status, stable qti:* code, safe detail, requestId, traceId, and optional fieldErrors. Public URI pages are part of the Platform contract, not a QTI deferral.

Alternatives and tradeoffs

  • no shared error envelope
  • plain text errors
  • plain RFC 7807 with free-text type values
  • QTI-local validation fields that diverge from Platform Problem

Rationale and consequences

one Platform Problem shape lets QTI, OneRoster, Caliper, CASE, and integrator clients share error handling while preserving QTI-specific codes

#qti_eventing_model

qti eventing model

Unspecified

Decision

DEFER poll, modifiedSince, and webhook eventing. The current surface has no public event feed or status feed.

Alternatives and tradeoffs

  • poll-only status endpoints
  • poll + modifiedSince
  • webhooks

Rationale and consequences

eventing adds retry, authorization, and callback semantics before the served audience needs them; current ingest/conformance workflows return accepted state and identifiers without a published long-running status model

#qti_idempotency_model

qti idempotency model

Unspecified

Decision

SHIP Idempotency-Key on package ingest only; use canonical XML/package hashes and If-Match for authoring saves; DEFER broad idempotency on delivery sessions and attempts.

Alternatives and tradeoffs

  • no idempotency
  • Idempotency-Key on sessions and attempts
  • optional package ingest keys

Rationale and consequences

package uploads are retry-prone and should not duplicate rows, while attempts and delivery sessions are semantically distinct runtime events unless the caller reuses returned identifiers

#qti_item_template_boundary

qti item template boundary

Unspecified

Decision

SHIP persist + interchange of item templates and SHIP server-side template realization; DEFER the rendering half to the client. qti-template-declaration round-trips into qti.variable_declaration variable_kind=template; qti-template-processing (qti-set-template-value / qti-template-constraint / qti-template-default) into qti.processing_rule scope=template; qti-printed-variable and math templates into qti.component. The platform runs qti-template-processing at delivery-session start to bind template variables and the correct response, honoring qti-template-default and retrying qti-template-constraint up to the ITD-021 100-attempt fail-closed bound, and stores the realized values in qti.attempt.template_state; scoring runs against the realized correct response. qti-printed-variable substitution and MathML rendering are client-side, read from delivery JSON / template_state. No new gap-fill table or field.

Alternatives and tradeoffs

  • defer template realization to the client like PCI rendering (template processing computes the correct response and score; a client could forge it -- the same tamper failure ITD-027 closes for timing)
  • freeze a single pre-rendered variant on ingest (breaks lossless round-trip; destroys per-delivery cloning)
  • add a platform-specific item-clone table for realized variants (realized values already live in qti.attempt.template_state per attempt; a clone table duplicates that primitive)
  • extend QTI with platform template fields (qti-template-declaration / qti-template-processing are stock and already project into the existing variable/processing tables)

Rationale and consequences

Template realization must be server-authoritative because template values feed the correct response and therefore the score; realization reuses the already-pinned server-side processing runtime (ITD-021) and its constraint retry, and the realized values reuse the existing attempt template_state -- zero new storage, the same server-owns-scoring / client-owns-rendering split as ITD-027 and ITD-030. Re-open as a template-realization-cache ITD only when a realized variant must be addressable independently of an attempt.

#qti_list_endpoints

qti list endpoints

Unspecified

Decision

SHIP tenant-owned enumeration on the three repository roots: GET /tenants/{tenantId}/qti/artifacts (listArtifacts) returns rows of at minimum {artifactId, artifactKind, qtiIdentifier, title, latestArtifactVersionId, createdAt}; GET /tenants/{tenantId}/qti/artifact-versions (listArtifactVersions) returns {artifactVersionId, artifactId, versionNumber, xmlHash, rootElement, createdAt} tenant-scoped through the owning qti.artifact; GET /tenants/{tenantId}/qti/packages (listPackages) returns {packageId, manifestIdentifier, qtiProfile, importStatus, packageHash, idempotencyKey, importedAt}. All three return the {items, nextCursor} envelope with cursor + limit paging, tenant-scoped auth per the existing routing contract, and 200 with an empty page for a fresh tenant, never 404. DEFER delivery-session, attempt, candidate, and result collection lists.

Alternatives and tradeoffs

  • write-and-remember with no collection lists (the prior locked DEFER, pinned 2026-05-29; voided by triage decision 2026-06-10-054 on platform3 issue #19)
  • document 'consumers must mirror' as the contract
  • Idempotency-Key replay as the only lost-response recovery path
  • enumerate only /artifacts and leave sibling roots returning 404

Rationale and consequences

a lost ingestContentPackage response permanently leaked the artifact unless the caller had durably persisted its Idempotency-Key, every consumer maintained a client-side mirror of artifactId->title, and 'what artifacts does this tenant own?' was one SELECT on the raw path but unanswerable on the API path — failing the binding two-paths rule and blocking the migration reconcile gate; artifactIds are the platform's canonical handles and must be recoverable from the surface itself

#qti_lost_response_recovery

qti lost response recovery

Unspecified

Decision

Publish the lost-response recovery pair as first-class contracts beside package ingest: (1) replay — when the caller persisted its Idempotency-Key, re-sending the same ingest with the same key returns the original response through platform.idempotency_key; (2) enumeration — when it did not, listArtifacts/listPackages locate the artifact by qtiIdentifier, title, or packageHash, resolve latestArtifactVersionId, and continue through the existing reads with no client-side state. Implementation ships the don't-break-it-twice regression suite: the issue #19 reproducer for-loop returns 200 with the documented envelope on every enumerated root, a lost-response drill recovers an ingested artifact end-to-end with no Idempotency-Key and no client-side state, a tenant-isolation probe, an empty-tenant 200 probe, and a same-key replay non-regression probe; the suite must fail on a pre-fix snapshot.

Alternatives and tradeoffs

  • replay-only recovery documentation
  • leaving recovery as troubleshooting folklore outside the published contract

Rationale and consequences

both recovery paths must exist as published contracts — replay when you persisted the key, enumerate when you didn't — so a lost ingest response is never a permanent leak; platform3 issue #19 stays open until the automated live reproduction (reproducer for-loop plus lost-response drill against production) passes

#qti_openapi_boundary_parseability

qti openapi boundary parseability

Unspecified

Decision

The downstream implementation OpenAPI boundary contract must parse as YAML/OpenAPI with a real parser before regex operation-ID checks can pass; scalar descriptions containing colon-space text must be quoted or block-scalared.

Alternatives and tradeoffs

  • regex-only operation checks
  • README prose as the only boundary contract
  • publishing unparsable OpenAPI and relying on generated docs

Rationale and consequences

integrators and reviewers consume the advertised OpenAPI document directly, so syntax validity is part of the API contract rather than a test-suite detail

#qti_pci_boundary

qti pci boundary

Unspecified

Decision

SHIP persist + interchange of Portable Custom Interactions; DEFER server-side PCI execution. qti-portable-custom-interaction (and legacy qti-custom-interaction), qti-interaction-markup, and qti-interaction-modules round-trip verbatim via XML authority + qti.component + delivery JSON; the PCI's hosted JavaScript module files persist as qti.package_file bytes indexed by qti.package_resource; the bound response is an ordinary qti.variable_declaration response declaration. The platform never executes vendor PCI JavaScript server-side: the delivery client renders the PCI and runs the IMS PCI getResponse/getState lifecycle, and the platform scores the resulting response value through ordinary server-side response/outcome processing. No new gap-fill table or field.

Alternatives and tradeoffs

  • execute PCI JavaScript server-side in a headless browser or JS sandbox (runs untrusted vendor code in the scoring path, the exact risk ITD-021 fails closed on)
  • normalize or drop PCI on ingest (breaks lossless round-trip, destroys the authored interaction)
  • store PCI JS modules outside the package (splits the package and breaks package-closure validation, ITD-009)
  • extend QTI with a platform-specific custom-interaction field (qti-portable-custom-interaction is the stock mechanism; the gap is runtime execution, not vocabulary)

Rationale and consequences

PCI markup reuses the same component/delivery-JSON path as every other interaction and its JS modules the same package-file path as every other asset; the only decision is the runtime-execution boundary, which the spec's client-side PCI lifecycle and ITD-021's no-vendor-code rule already place on the client. Re-open as a sandboxed-PCI-execution ITD only when a consumer needs headless server-side PCI scoring.

#qti_platform_audit_log_inheritance

qti platform audit log inheritance

Unspecified

Decision

QTI writes, privileged reads, learner-runtime deletion, conformance/trust mutations, idempotency outcomes, and authorization denials emit redacted platform.audit_log rows carrying the same requestId and traceId returned in API responses.

Alternatives and tradeoffs

  • QTI-local audit table
  • application logs only
  • audit rows containing raw package bytes, tokens, headers, or direct learner PII

Rationale and consequences

platform.audit_log is the approved cross-surface audit primitive and QTI operations need traceable but redacted evidence

#qti_platform_idempotency_key_inheritance

qti platform idempotency key inheritance

Unspecified

Decision

Retryable package ingest records and replays through platform.idempotency_key, linked from qti.content_package.platform_idempotency_key_id; QTI must not become a second retry ledger.

Alternatives and tradeoffs

  • QTI-local idempotency table
  • idempotency embedded only in content_package.idempotency_key text
  • no package-ingest idempotency

Rationale and consequences

one shared retry ledger prevents cross-module drift while package-row evidence keeps QTI imports traceable

#qti_platform_tenant_inheritance

qti platform tenant inheritance

Unspecified

Decision

QTI tenant-owned tables use tenant_id as a foreign key to platform.tenant(tenant_id); qti.tenant is only a compatibility view or bridge during migration and must not become a second tenant authority.

Alternatives and tradeoffs

  • QTI-local tenant table as source of truth
  • tenant only in JWT
  • tenant stored inside QTI XML

Rationale and consequences

platform.tenant is the cross-surface identity primitive, so QTI must inherit it to keep tenant routing and audit consistent with the approved Platform module

#qti_privacy_retention

qti privacy retention

Unspecified

Decision

SHIP permission-gated tenant-scoped learner-runtime read and deletion by pseudonymous candidateRef: GET /tenants/{tenantId}/qti/candidates/{candidateRef}/runtime-data returns authorized sessions and attempts for one candidate; DELETE deletes delivery sessions and cascading attempts while retaining reusable package, artifact, version, resource, file, and conformance rows. DEFER age-based automatic retention until tenant policy exists.

Alternatives and tradeoffs

  • no runtime-data read or deletion
  • delete reusable content records with learner runtime data
  • collection-wide runtime browsing
  • age-based automatic deletion without tenant policy

Rationale and consequences

candidate-scoped read supports review/debug workflows and candidate-scoped deletion removes learner runtime data without corrupting reusable QTI content; retention windows are policy commitments outside the QTI spec

#qti_query_model

qti query model

Unspecified

Decision

SHIP cursor + limit as the enumeration lists' paging primitives: an opaque cursor (only ever a prior page's nextCursor), an integer limit (default 50, maximum 200), the {items, nextCursor} envelope, and stable ascending (creation timestamp, primary-key id) ordering. DEFER filter, sort, and modifiedSince on their own merits — this is an enumeration fix, not a general query-surface expansion. Fixture helpers may accept optional runId only to vary public-demo fixture identifiers and hashes; runId is not a collection filter, sort key, page cursor, or freshness query.

Alternatives and tradeoffs

  • no query model at all (the prior chained deferral, regenerated narrowly by triage decision 2026-06-10-054)
  • filter + sort
  • filter + cursor paging + modifiedSince
  • offset/page-number paging

Rationale and consequences

cursor + limit is the smallest primitive that makes enumeration complete and stable under concurrent ingest; filter/sort have no current consumer and modifiedSince would imply polling semantics the surface does not publish

#qti_read_shape

qti read shape

Unspecified

Decision

SHIP tenant-owned enumeration of the three repository roots (listArtifacts, listArtifactVersions, listPackages per qti_list_endpoints) plus operation-specific projection/detail reads by known identifiers: GET delivery-json and GET XML for a known artifactVersionId, GET latest lossless authoring-json for a known artifactId, GET candidate runtime-data for a known pseudonymous candidateRef, and public demo fixture downloads GET /fixtures/qti-package.zip and GET /fixtures/qti-package.xml. DEFER sub-collection resource browsing.

Alternatives and tradeoffs

  • list-free write-and-remember (superseded by triage decision 2026-06-10-054)
  • list + detail + sub-collections for sessions/attempts/results
  • using delivery-json as the source for authoring edits

Rationale and consequences

the list-free shape made every lost write response a permanent leak and every consumer a mirror maintainer (platform3 issue #19); sub-collection browsing stays deferred because the shipped detail reads already answer the documented workflow

#qti_results_reporting_boundary

qti results reporting boundary

Unspecified

Decision

SHIP QTI Results Reporting + Usage Data persist/interchange; SHIP the generated assessmentResult projection via the existing learner-runtime read; DEFER a dedicated results-export endpoint; DEFER Caliper event emission to the Caliper module. A QTI 3.0 assessmentResult document round-trips as artifact_kind=result (element vocabulary assessmentResult / context / testResult / itemResult / responseVariable / outcomeVariable / templateVariable / candidateResponse / sessionIdentifier, pass-through from imsqti_resultv3p0_v1p0.xsd); QTI Usage Data (qti-usagedata incl. item statistics / IRT a-b-c) persists as artifact_kind=usage-data pass-through, never promoted to platform columns. A stored attempt's response/template/outcome state is the assessmentResult content, returned today by getCandidateRuntimeData; a standalone results-export endpoint is deferred. Emitting Caliper events (AssessmentEvent, AssessmentItemEvent, GradeEvent) is the Caliper 1EdTech module's job, not the QTI surface's -- the QTI surface emits no Caliper events, stores no Caliper profile, and owns no Caliper vocabulary. No new gap-fill table or field.

Alternatives and tradeoffs

  • promote results-reporting and usage-data values into platform columns/tables (assessmentResult is a projection of attempt state already stored; item statistics belong in the QTI Usage Data file; promotion duplicates the attempt rows and usage-data artifact)
  • ship a dedicated results-export endpoint now (no shipped consumer needs a standalone assessmentResult document beyond the learner-runtime read; it would expand the fixed operation set without a persona-blocking need)
  • emit Caliper events from the QTI surface at attempt submit (Caliper is its own 1EdTech module; emitting here duplicates that module's primitive and splits event ownership)
  • extend QTI with a platform-specific results or Caliper field (the vocabulary exists in the QTI Results Reporting and Caliper specs; the only question is module ownership)

Rationale and consequences

Results documents reuse the result and usage-data artifact kinds already in artifact_kind_ck, the generated projection reuses attempt state and the existing learner-runtime read, and Caliper stays in its own module -- zero new storage or vocabulary. The ITD only draws the two boundaries the architecture left implicit: QTI results vs. platform columns, and the QTI surface vs. the Caliper module. Re-open a results-export endpoint when a consumer needs a standalone assessmentResult document; re-open QTI-side Caliper emission only if a platform-level decision moves event ownership off the Caliper module.

#qti_route_scoped_cors

qti route scoped cors

Unspecified

Decision

SHIP route-scoped CORS: OPTIONS computes Access-Control-Allow-Methods from the matched route's served methods plus OPTIONS, so POST-only routes advertise POST, OPTIONS, GET/PUT paired routes advertise only their actual served methods plus OPTIONS, and fixture helper routes advertise GET, OPTIONS only.

Alternatives and tradeoffs

  • global Access-Control-Allow-Methods superset
  • no CORS preflight
  • static per-deployment method list

Rationale and consequences

route-scoped CORS keeps browser clients honest about the actual API contract and avoids implying unsupported methods, especially for public fixture helper routes that must not imply write or tenant-resource access

#qti_served_audiences

qti served audiences

Unspecified

Decision

developer building a Pearson-Assessment-shaped clone (an assessment authoring and delivery product where teachers create QTI items and students take attempts) on top of the QTI 1EdTech surface alone

Alternatives and tradeoffs

  • one-off content migration tool
  • QTI spec validator without delivery
  • internal-only consumers

Rationale and consequences

the sharp persona makes the persona-blocking test concrete; without this anchor every feature request looks deserving

#qti_tenant_routing

qti tenant routing

Unspecified

Decision

SHIP tenant in URL: /tenants/{tenantId}/qti/... for tenant-owned operations, with JWT tenant claim matching the path tenant. Public fixture helper routes intentionally have no tenant segment because they return tenant-neutral demo bytes and no repository state.

Alternatives and tradeoffs

  • tenant in JWT only
  • tenant in header
  • tenant encoded in QTI XML or metadata

Rationale and consequences

URL-pinned tenant makes request scope visible in examples and logs while keeping tenant isolation metadata out of QTI content; adding tenant routing to fixture downloads would imply tenant-specific fixture state that the surface does not ship

#qti_timed_delivery_contract

qti timed delivery contract

Unspecified

Decision

SHIP server-authoritative enforcement of QTI qti-time-limits plus server-side time-conditioned outcome processing. When an ingested assessment test (or its test part, section, or item ref) declares qti-time-limits, delivery JSON surfaces the declared timeLimits block (maxTime, minTime in seconds as QTI NonNegativeDouble; allowLateSubmission boolean, QTI default false) so a client countdown is display-only; the delivery session records a server-authoritative window_started_at and derived window_expires_at (with PNP extended-time applied to effective_max_time_seconds); at submitAttempt the server computes the QTI built-in duration response variable from its own timestamps (never client-supplied) and sets attempt timing_status to one of untimed, in_window, late_accepted, or late_rejected; a submission beyond max-time with allow-late-submission=false is rejected with a typed Platform Problem qti:time-limit-exceeded (HTTP 422) and never silently scored as in-window, while allow-late-submission=true accepts the attempt and flags it late_accepted with the flag visible in scoring; the declared QTI 3 outcome processing executes server-side and the scoring response returns the declared outcome variables (not raw/max only), with the duration built-in available so a time-bounded mastery rule and CQPM are computed by the platform; the QTI surface executes whatever outcome processing the ingested test declares and does not own mastery/passing thresholds (canonical passing-rule ownership stays in the test_spec Content domain); a test with no declared time limits behaves exactly as today (untimed). Implementation must ship the don't-break-it-twice regression suite: ingest a max-time=180 test with a time-bounded mastery outcome (delivery JSON exposes the limit); in-window attempt scored with the declared mastery outcome present; post-window attempt with allow-late-submission=false returns 422 qti:time-limit-exceeded; post-window attempt with allow-late-submission=true is accepted-but-flagged late_accepted; a tamper probe asserting client-supplied timing is ignored; and a non-regression probe for an untimed test. The suite must fail on a pre-fix snapshot, and the originating AcmeTest timed-delivery issue stays open until an automated live reproduction confirms enforcement and the computed mastery outcome hold.

Alternatives and tradeoffs

  • keep client-side timing and locally-computed mastery (tamperable; forces the integrator to own scoring semantics)
  • trust a client-supplied elapsed/duration value on the attempt (forgeable; only server timestamps are authoritative)
  • extend QTI with new Alpha time or fluency-mastery fields (QTI already defines qti-time-limits, allow-late-submission, the duration built-in, and outcome processing; the gap is enforcement+execution, not vocabulary)
  • reject all late submissions unconditionally (contradicts allow-late-submission=true accept-but-record semantics)
  • compute mastery/passing inside the QTI surface as a platform-owned rule (passing-rule ownership is canonical in the test_spec Content domain)

Rationale and consequences

A platform whose pitch is trustworthy third-party-validated outcomes cannot let mastery evidence depend on a client countdown a student can pause or extend via devtools; the fix makes the runtime actually execute QTI semantics the surface already ingests, reusing the existing delivery-session/attempt rows, the typed Platform Problem envelope, and the server-side processing runtime, and it deletes the integrator's tamperable countdown and local mastery math (negative-complexity test).

#qti_write_granularity

qti write granularity

Unspecified

Decision

SHIP workflow-specific per-resource writes: POST /tenants/{tenantId}/qti/packages, PUT /tenants/{tenantId}/qti/artifacts/{artifactId}/authoring-json, POST /tenants/{tenantId}/qti/delivery-sessions, POST /tenants/{tenantId}/qti/delivery-sessions/{deliverySessionId}/attempts, DELETE /tenants/{tenantId}/qti/candidates/{candidateRef}/runtime-data, and POST /qti/conformance-runs. Generic artifact-version mutation routes are deferred.

Alternatives and tradeoffs

  • bulk-only package import
  • direct table CRUD
  • generic artifact-version CRUD

Rationale and consequences

the served audience needs authoring, delivery, attempts, deletion, and conformance workflows, while generic artifact-version CRUD duplicates saveAuthoringJson and confuses immutable version semantics