Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,22 @@ The project is pre-1.0; breaking changes are acceptable and explicitly called ou

### Added

- **`CanonWorkItem.description`** ([AMPR-269](https://linear.app/miley/issue/AMPR-269)).

Deferred at admission (AMPR-262) for failing the bulk rule — a GitHub issue
body routinely exceeds the 32 KiB per-projection budget, and `WORK_ITEM` was
the wave's strongest admission candidate. `CanonProse` (AMPR-268) now gives
it a bounded shape, so the field lands additively with a `null` default: a
bounded snippet, not the provider's full description, which continues to
survive losslessly in `CanonProvenance.nativePayload`. Three provider
formats collapse to one field — Jira's ADF (a structured JSON tree, not
text), GitHub-flavoured Markdown, and Linear's Markdown — each losing
structure a flattening to prose cannot carry back; named in KDoc alongside
the wave's other lossy fields. `CanonProject.summary` already moved to
`CanonProse` as part of AMPR-268, so no further migration was needed there.
Pinned by a maximally-filled `CanonWorkItem` in the existing
`CanonWorkEntitiesTest` projection-budget assertion.

- **Knowledge-work canon wave: `WORK_ITEM`, `PROJECT`, `MILESTONE`, `TABLE`**
([AMPR-262](https://linear.app/miley/issue/AMPR-262)).

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,6 @@ data class CanonThirdPartyPlaylist(
* Lossy on every provider: `priority` (three incompatible scales — Linear's
* 0–4 ordinal, Jira's named object, GitHub's labels-by-convention), `estimate`,
* `parent`/`children`, and label colour, since [labels] keeps names only.
* `description` is deliberately absent — it is unbounded provider prose with no
* bulk-rule story yet, and it survives losslessly in
* [CanonProvenance.nativePayload] meanwhile.
*
* @property dueAt **Lossy by type, not by omission.** Linear `dueDate` and Jira
* `duedate` are calendar dates with no time and no zone; adapters normalise to
Expand All @@ -160,6 +157,16 @@ data class CanonThirdPartyPlaylist(
* as "definitely standalone". Resolvable only against entities from the same
* Link, and nothing guarantees the referenced [CanonProject] was ever
* perceived. Follows the [CanonEmailMessage.mailboxId] precedent.
* @property description A bounded snippet, not the item's full description —
* see [CanonProse]. Three formats collapse to one field, each losing
* structure a flattening to prose cannot carry back: Jira's is **ADF
* (Atlassian Document Format), a structured JSON tree, not text** —
* flattening it drops mentions, panels, code blocks, and embedded media.
* GitHub's is GitHub-flavoured Markdown; Linear's is Markdown. Deferred at
* admission (AMPR-262) for failing the bulk rule — a GitHub issue body
* routinely exceeds the 32 KiB projection budget — until [CanonProse] gave
* it a bounded shape (AMPR-268). The full provider value survives losslessly
* in [CanonProvenance.nativePayload] regardless.
*/
@Serializable
@SerialName("canon.work_item")
Expand All @@ -173,6 +180,7 @@ data class CanonWorkItem(
val projectId: CanonId? = null,
val dueAt: Instant? = null,
val labels: List<String> = emptyList(),
val description: CanonProse? = null,
) : CanonEntity {
override val canonType: CanonType get() = CanonType.WORK_ITEM
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ class CanonWorkEntitiesTest {
projectId = CanonId("pj-1"),
dueAt = Instant.fromEpochMilliseconds(1_700_086_400_000),
labels = listOf("api", "spike", "architecture"),
description = CanonProse.bounded("Admit WORK_ITEM, PROJECT, MILESTONE and TABLE to the canon."),
)

assertEquals(entity, roundTrip(entity))
Expand Down Expand Up @@ -242,6 +243,35 @@ class CanonWorkEntitiesTest {
assertEquals(maxed, roundTrip(maxed), "worst-case prose must survive the round trip intact")
}

@Test
fun `a worst case work item serializes within the projection budget`() {
// AMPR-269: description was deferred at admission (AMPR-262) for
// failing the bulk rule; this pins a maximally-filled CanonWorkItem
// now that CanonProse gives it a bounded shape.
val worstCaseChar = "漢"
val maxed = CanonWorkItem(
canonId = CanonId("wi-1"),
provenance = mcpProvenance,
title = "x".repeat(200),
status = CanonWorkStatus.IN_PROGRESS,
providerStatus = "In Review",
assignee = CanonPerson(CanonId("p-1"), mcpProvenance, displayName = "Miley"),
projectId = CanonId("pj-1"),
dueAt = Instant.fromEpochMilliseconds(1_700_086_400_000),
labels = List(20) { "label-$it" },
description = CanonProse.bounded(worstCaseChar.repeat(CanonProse.MAX_CHARS * 3)),
)

val encoded = json.encodeToString(CanonEntity.serializer(), maxed)

val byteCount = encoded.encodeToByteArray().size
assertTrue(
byteCount <= projectionBudgetBytes,
"a worst-case canon.work_item serialized to $byteCount bytes over a $projectionBudgetBytes byte budget",
)
assertEquals(maxed, roundTrip(maxed), "worst-case fields must survive the round trip intact")
}

@Test
fun `bounding never splits a surrogate pair`() {
// A lone high surrogate is not valid UTF-8; it would encode as a
Expand Down
Loading