From 8a2c0b09aacf3445c9fd5ea27c1c5a60e8c88d6d Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Sat, 1 Aug 2026 11:33:44 -0500 Subject: [PATCH] AMPR-269 #673: Admit CanonWorkItem.description now that CanonProse bounds it Deferred at admission (AMPR-262) for failing the bulk rule; CanonProse (AMPR-268) gives it a bounded shape, so the field lands additively with a null default. Names the loss per provider in KDoc (Jira ADF, GitHub-flavoured Markdown, Linear Markdown) and pins a maximally-filled CanonWorkItem under the existing projection-budget assertion. CanonProject.summary already moved to CanonProse under AMPR-268, so no further migration was needed there. I am the author of this commit. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 16 ++++++++++ .../socket/ampere/canon/CanonRing3Entities.kt | 14 +++++++-- .../ampere/canon/CanonWorkEntitiesTest.kt | 30 +++++++++++++++++++ 3 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 65df3a0c..51ff5f92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)). diff --git a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonRing3Entities.kt b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonRing3Entities.kt index ef78728f..6fbc0903 100644 --- a/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonRing3Entities.kt +++ b/ampere-core/src/commonMain/kotlin/link/socket/ampere/canon/CanonRing3Entities.kt @@ -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 @@ -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") @@ -173,6 +180,7 @@ data class CanonWorkItem( val projectId: CanonId? = null, val dueAt: Instant? = null, val labels: List = emptyList(), + val description: CanonProse? = null, ) : CanonEntity { override val canonType: CanonType get() = CanonType.WORK_ITEM } diff --git a/ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/CanonWorkEntitiesTest.kt b/ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/CanonWorkEntitiesTest.kt index 61b9cf1e..7e85bdd0 100644 --- a/ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/CanonWorkEntitiesTest.kt +++ b/ampere-core/src/commonTest/kotlin/link/socket/ampere/canon/CanonWorkEntitiesTest.kt @@ -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)) @@ -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