App History Design Review
Scope
This report reevaluates finding #3 from v2-discrepancy-report.md against the rules in design-guidelines.md, using the current app_history table and the existing V1/frontend behavior as the concrete baseline.
It answers two questions:
- Should app approval events, notes, and audit trail exist as a first-class V2 resource?
- Is the current
app_historytable a sound backing model for that resource?
Inputs Reviewed
- Finding
#3: v2-discrepancy-report.md - API rules: design-guidelines.md
- Current Apps docs: apps/index.md
- V1 contract:
- Current persistence:
- Frontend dependency:
Executive Summary
Finding #3 remains valid.
The missing V2 app history contract is not just a docs gap. It is a resource-model gap:
- the frontend currently depends on app-scoped history for approval changes and notes,
- the backend already persists these events in a dedicated
app_historytable, - the design guidelines favor a separate child collection for unbounded, time-ordered, non-static data.
Conclusion: app history should be a first-class V2 child resource under an app. The current table is a workable foundation, but the API contract should normalize its shape rather than exposing the table too literally.
Findings
1) App history should be a separate endpoint, not embedded in App
This follows the embedding rule directly.
App -> History Events fails all three embed conditions from the guidelines:
| Condition | Result | Why |
|---|---|---|
| Bounded | No | History is open-ended over time |
| Always needed for display | No | App list/detail can render without full timeline history |
| Static reference data | No | Events are mutable business activity, not static metadata |
Decision: expose history as its own endpoint, not as an embedded field on App.
This is fully aligned with the guideline:
Embed small, stable, always-needed reference objects. Everything else gets its own endpoint.
2) The current V1 route shape is directionally correct
The existing V1 routes are:
GET /clients/{client_id}/apps/{app_id}/historyPOST /clients/{client_id}/apps/{app_id}/historyPOST /clients/{client_id}/apps/{app_id}/history/notes
That shape largely fits the guidelines:
- history is modeled as a child collection under a specific app,
- reads use
GET, - writes use
POST, - the list endpoint is cursor-paginated and returns
{ data, total_count, next_cursor }.
The only design weakness is that POST /history mixes two concerns:
- changing the app approval state,
- creating an audit event.
From a resource-design perspective, the approval state change belongs to the app or approvals domain, while the history entry is the resulting audit record. V1 collapses both into one endpoint for UI convenience.
Recommendation: keep history as the read model, but make the write model explicit in V2:
- approval writes should happen through app approval endpoints,
- note creation can remain a history child-resource write,
- approval endpoints should create history records as a side effect.
3) The current table design is a valid audit-log foundation, but not a clean API shape
Current table:
| Column | Assessment |
|---|---|
id | Good. Stable event identifier. Matches guideline use of id. |
app_id | Good in storage. Should not be required in the DTO when the app is already in the URL path. |
client_id | Good in storage for tenancy and indexing. Should be omitted from V2 DTOs because the guidelines say path-scoped tenant IDs stay out of payloads. |
event_type | Good. Required event discriminator is necessary. |
actor_user_id | Good. Nullable is appropriate for system-generated events. |
created_at | Good. Matches audit timestamp guidance. |
comment | Good for note-like or annotated approval events. |
message | Acceptable but weakly defined. Useful for display, but should be treated as presentation text, not canonical business state. |
status | Weak. It encodes approval state as free-text (approved, not_approved) instead of a typed domain field. |
metadata | Useful escape hatch, but under-specified. Without event-specific shape rules it can become a junk drawer. |
The table is good enough as persistence because audit/event tables often need some flexibility. It is not a good direct DTO because several columns are storage-oriented rather than consumer-oriented.
4) The table already proves the resource exists in the domain
This is not a speculative future feature:
- ingestion writes
app_createdevents, - approval updates write
approval_status_changedevents, - notes are inserted as
note_added, - the frontend renders the timeline as a user-facing feature today.
So the correct V2 decision is not "do we need history?" but "what is the stable V2 contract for history?"
5) The current pagination contract needs a stable tie-breaker
The implementation orders history by created_at DESC and uses a cursor derived from the last event id, but the SQL page boundary is still based only on created_at.
That is not a stable cursor when multiple events share the same timestamp:
- two rows can have the same
created_at, - page 1 can end on one of those rows,
- page 2 filters on
created_at < boundary_created_at, - sibling rows with the same timestamp can be skipped.
This is an API-design issue as much as an implementation issue because the guidelines require cursor-based pagination for collections.
Recommendation:
- sort by
(created_at DESC, id DESC), - encode both values in the cursor,
- page with tuple comparison semantics rather than timestamp alone.
6) Actor identity should be modeled as an embedded actor ref, not split scalar fields
The current V1 payload uses:
actor_user_idactor_display_name
That is workable, but it is not the cleanest V2 shape.
The repo-wide DTO pattern favors embedding a small reference object when another entity appears inside a non-primary resource. However, this should not reuse UserRef.
Why UserRef is wrong here:
UserRefis the end-userusersdomain DTO,- app-history actors currently come from
partner_users, UserRefincludes end-user fields such asusername,user_key, andlast_activitythat do not belong on an audit event actor.
Recommendation:
- replace
actor_user_id+actor_display_namewithactor: ActorRef | null, - define
ActorRefas an audit-oriented embedded DTO, not asUserRef, - keep it intentionally small.
Recommended shape:
{
"actor": {
"id": "1b8fbc0f-f234-4da7-9cb2-5ae10ef63b8e",
"display_name": "Jane Smith"
}
}
If the domain may later include multiple actor sources, this can evolve safely to:
{
"actor": {
"type": "partner_user",
"id": "1b8fbc0f-f234-4da7-9cb2-5ae10ef63b8e",
"display_name": "Jane Smith"
}
}
This keeps the event DTO aligned with the existing embedded-ref pattern without binding it to the wrong resource domain.
7) Actor display name is not audit-stable today
Even if the event DTO moves to actor: ActorRef | null, the audit-stability problem remains. The current table does not store actor display name, and the read path joins partner_users at query time.
That means historical output can change when:
- a user is renamed,
- a user is deleted or anonymized,
- display-name formatting rules change.
For a simple activity feed this may be acceptable. For an audit trail it is a weakness because the event does not fully preserve what was known at write time.
Recommendation:
- if "audit trail" is the intended contract, snapshot
actor.display_nameinto the event record at write time, - if that is too strong a requirement, document that the returned actor label is a presentational join, not immutable audit data.
8) The main guideline mismatch is omission, not structure
Against design-guidelines.md, the biggest violation is that V2 omits a needed separate resource even though the underlying data is:
- unbounded,
- time-ordered,
- user-visible,
- already backed by a dedicated table,
- already required by the frontend.
The design guidelines do not require every table to get an endpoint. But once a workflow depends on an unbounded child collection, the guidelines point toward a dedicated endpoint. That is the missing piece.
Table-Level Evaluation
What is strong
- Dedicated audit table instead of overloading
apps.updated_at - Correct parent linkage through
app_id - Tenant scoping through
client_id - Proper event timestamp via
created_at - Support for human and system actors through nullable
actor_user_id - Index on
(client_id, app_id, created_at DESC)matches the primary read pattern
What is weak
event_typeis unconstrainedvarcharstatusis unconstrainedvarcharmessageoverlaps with derived UI copy and can drift in wordingmetadatahas no documented per-event contract- actor identity is currently exposed as split scalar fields instead of a small embedded ref
- actor display name is not snapshotted in the event record
- pagination currently depends on
created_atordering without a stable tie-breaker - no
updated_at, which is fine for immutable audit events, but that immutability should be explicit in docs
What should stay out of the public DTO
Per the guidelines, these DB fields should not be part of the default V2 event payload:
client_idapp_id
They are already supplied by the URL path context.
Proposed V2 Resource Model
Recommended endpoints
Read model:
GET /clients/{client_id}/apps/{app_id}/history
Write models:
POST /clients/{client_id}/apps/{app_id}/history/notesPOST /clients/{client_id}/apps/approvalsPOST /clients/{client_id}/apps/approvals/remove
Behavior:
- approval endpoints update app approval state and append history events,
- note endpoint appends a note event only,
- history list returns the full audit trail in reverse chronological order.
This keeps business commands separate from the audit timeline while preserving the current UX.
Recommended DTO
Recommended V2 event DTO:
{
"id": "9f4f8c52-3d85-4d0f-bec3-d95e890d1d24",
"event_type": "approval_status_changed",
"actor": {
"id": "1b8fbc0f-f234-4da7-9cb2-5ae10ef63b8e",
"display_name": "Jane Smith"
},
"message": "approved this app",
"comment": null,
"metadata": {
"is_approved": true
},
"created_at": "2026-03-06T19:42:11.000Z"
}
Notes:
- omit
client_idandapp_idfrom the DTO, - keep
idandcreated_at, - keep
event_typeas the stable discriminator, - use
actor: ActorRef | nullrather than split actor scalar fields, - keep
comment, but treat it as primarily note-event content rather than the canonical representation of approval changes, - keep
messageonly if the API intends to own timeline copy, - prefer event-specific
metadataover exposing a genericstatusstring. App.is_approvedandAppHistoryEvent.metadata.is_approvedshould remain distinct:App.is_approvedis the app's current stateAppHistoryEvent.metadata.is_approvedis the result of that specific approval event
Recommended ActorRef
Suggested reusable embedded DTO:
{
"id": "1b8fbc0f-f234-4da7-9cb2-5ae10ef63b8e",
"display_name": "Jane Smith"
}
| Field | Type | Nullable | Description |
|---|---|---|---|
id | string (uuid) | No | Stable unique identifier for the actor record |
display_name | string | No | Human-readable name to render in timeline UI |
Notes:
- this is intentionally not
UserRef, - it is an audit-oriented identity projection, not a full actor resource,
- if system-generated events need to be represented without a separate sentinel string, use
actor: null, - if multiple actor domains are expected later, extend this to include
type.
Future-compatible variant:
{
"type": "partner_user",
"id": "1b8fbc0f-f234-4da7-9cb2-5ae10ef63b8e",
"display_name": "Jane Smith"
}
I would not add username, user_key, last_activity, or other UserRef fields here. They are not needed to render app-history events and would couple the audit DTO to the wrong domain model.
Event type recommendations
At minimum, preserve these event types:
app_createdapproval_status_changednote_added
app_edited should only remain if there is a real producer and a documented payload shape for it.
Metadata recommendations
If metadata is exposed, document it per event_type.
Example:
approval_status_changedis_approved: boolean
app_created- no metadata required
note_added- no metadata required unless future note-editing semantics are added
Avoid making consumers infer business meaning from message text.
Should status exist?
Not in the V2 API contract.
Reasons:
- it duplicates information already implied by
event_typeplusmetadata, - it is stored as free text rather than a strongly typed field,
- it is approval-specific and does not generalize well across all event types,
- it leaks storage normalization into the public contract.
Recommendation: keep status internal for back-compat if needed, but do not document it as part of the V2 DTO.
Design-Guideline Compliance
Aligns well
- Separate endpoint for unbounded child collection
- Cursor-paginated list response
idas the resource identifiercreated_ataudit timestamp- Omission of path-scoped tenant IDs from DTOs
Needs explicit documentation
- history is append-only and immutable,
- event ordering is newest-first,
next_cursorpagination contract,- event-specific meaning of
metadata, - which writes create history side effects.
Recommended decision
Approve app history as in-scope for V2 and document it as a dedicated child collection.
Use the current table as the persistence model, with these contract decisions:
- Expose
GET /clients/{client_id}/apps/{app_id}/historyin V2. - Expose
POST /clients/{client_id}/apps/{app_id}/history/notesfor notes. - Keep approval writes on approval endpoints, not on
POST /history. - Do not expose
client_id,app_id, or rawstatusin the public event DTO. - Define a stable
event_typeenum and event-specificmetadatashapes. - Make history pagination deterministic with
(created_at, id)cursor ordering. - Expose actor as
ActorRef | null, notactor_user_idplusactor_display_name. - Decide whether actor display name is immutable audit data and, if yes, persist a snapshot.
Follow-on docs to create
This report supports adding the following docs next:
docs/api/apps/history.mddocs/api/apps/history-notes.md- update
docs/api/apps/index.mdto include history endpoints - update
docs/api/v2-discrepancy-report.mdfinding#3from "missing/undecided" to "accepted design gap with proposed contract"