Repository navigation
Seeds cannot address an ActivityPointer: sys_activity.record_id is plain text, so a packaged app can load timeline rows that attach to nothing #11339
Description
Summary
sys_activity rows are the only thing the record:activity timeline renders, and a packaged app cannot ship a single usable one. Seeds load the rows happily — every gate reports success — but the rows attach to no record, so the timeline stays empty. The ActivityPointer model (ADR-0052 §5) is documented as the queryable drill from a one-line summary to the rich entity, and today no app can demonstrate it out of the box.
Found while implementing objectstack-ai/hotcrm#1258 (seed demo interactions so the lead Activity tab shows something). Measured against 17.1.0.
What was measured
Three type: 'completed' rows were seeded for a demo lead, then read back from the running server.
The write half works. build is silent, the boot banner counts the rows (Seeds: … 345 rows, up from 342), and every column lands — readonly included, since seeds write under { isSystem: true } and sys_activity declares every field readonly. mode: 'upsert' is accepted despite managedBy: 'append-only'; cel temporals resolve.
The addressing half does not. sys_activity.record_id is Field.text(), not a lookup, and the seed loader resolves natural keys only for lookup / master_detail fields — everything else is stored verbatim. The target record's id is a nanoid that does not exist until first boot, so there is no value an author can write.
Using the filter the shipped console bundle actually issues — sys_activity, { $filter: { object_name, record_id }, $orderby: { timestamp: 'desc' } }:
filter { object_name: 'crm_lead', record_id: 'tgIjpNhjlfmWU8YF' } → 0 rows (the real lead id)
filter { object_name: 'crm_lead', record_id: 'Lisa Thompson' } → 3 rows (the literal natural key)
Why this is a platform gap and not an app workaround
The app-side routes are all worse than the gap:
- A hook or flow that writes the pointer row at bootstrap would be a second producer of interaction rows alongside the
log_call/log_meeting/send_emailaction bodies — every action-created interaction would get two rows. That is the two-producer conflict hotcrm#702 already ruled against oncrm_forecast. - A post-boot script driving the shipped actions over HTTP works, but then
demo:reset && devalone still shows an empty tab — the demo is no longer self-contained, which is the whole point of seed data. - Widening the consumer (having the timeline also match on
record_label) is a tolerant-consumer fix for a producer-side gap, and would make the feed match unrelated records that happen to share a label.
The general rule is already settled and correct — a seed cannot name an id that does not exist yet — and reference resolution is its one sanctioned exemption. The gap is that the ActivityPointer pair is excluded from that exemption purely because it is typed as text.
Possible shapes (not a recommendation — needs a decision)
- Make the pointer pair resolvable in seeds. Teach the loader that
object_name+record_id(andsource_object+source_id) form a polymorphic reference, sorecord_idaccepts the target's natural key and resolves it against the object named in the sibling column. Declared = enforced; no consumer change. - A polymorphic reference field type that
sys_activityadopts, making the pair a first-class relationship the loader already knows how to resolve — larger, but it also gives the pair referential integrity, which it has none of today. - Refuse the write instead of accepting it. If seeding an ActivityPointer is not intended to be supported, the loader should reject a seed whose unresolvable text column is a declared pointer target, rather than storing a literal that silently renders nothing. This is the cheap half and is worth doing regardless of 1 or 2 — the current behaviour is a success receipt for configuration that does nothing.
Adjacent facts worth carrying into the decision
sys_activityislifecycle.class: 'telemetry'withretention: { maxAge: '14d' }and day-shard rotation (sys_activity__r<YYYYMMDD>behind a view). Even with addressing solved, seeded demo history older than a fortnight is reaped by design — so "seedable timeline" may need a retention answer too, or demo rows need to stay inside the window.sys_activitydeclares noowner_id/organization_id, so ownership scoping is not an obstacle here — the seeded rows read back fine over REST as the dev admin.
Downstream
hotcrm shipped a guard rather than a workaround — objectstack-ai/hotcrm#1260 holds seeds inside the app's own object graph, with this measurement recorded in the test comment. If this card lands, that guard should be deleted deliberately.
Activity
os-zhuang commentedon Aug 23, 2026
Claim: direct-dispatch (hotcrm seat acting on the platform card under maintainer instruction)
Session:
session_61f28fb9-8e31-4a6f-b4d8-10f749a092d2Branch:
claude/issue-11339-seed-activity-pointerWorktree:
objectstack-issue-11339File surface:
packages/metadata-protocol/src/seed-loader*.tsand thesys_activitydeclaration site (re-derived by the dev; expected underpackages/platform-objects/orpackages/plugins/plugin-audit/), plus tests. Spec (packages/spec) only if the chosen shape requires a declared carrier — flagged before editing (stop on breach; explain in the report).Container & model:
L,mode:subagent,model: fable— Clause-②: yes: whichever half ships (refuse the unresolvable write, or make the pointer resolvable) changes the seed contract's accept/reject behaviour. Mandatory fable tier per the dispatch skill's clause ②.Serial constraints cleared:
git logonseed-loader.tsshows last touch #9172 (not same-day); no open PR names the file (REST pulls listing checked at claim time).Authorization — maintainer direct-dispatch, verbatim (未译): 「1258 A,11339 你可以直接处理。」 and 「继续处理 11339」 (in-session, 2026-08-23). Downstream consumer: objectstack-ai/hotcrm#1258 is
pm:blockedon this card (Route A ruled).os-zhuang commentedon Aug 23, 2026
Flagging before editing, per the claim's spec condition: the chosen shape is the declared pointer-pair (issue option 1 + the refuse half of option 3), and no declaration carrier exists today — verified by survey: the
(object_name, record_id)pair idiom appears on six system objects (sys_activityx2 pairs,sys_audit_log,sys_approval_request,sys_record_share,sys_share_link,sys_automation_run.trigger_*), allField.text(), none machine-readable; the only "polymorphic" marker in spec (xRef) is a flow-config annotation, not a data-field concept.So the fix requires the declared carrier, and the file surface widens to
packages/spec/src/data/field.zod.ts(one optional authorable key next toreference, plus its contradiction checks) andpackages/spec/src/data/object.zod.ts(create-time sibling-existence assertion, following the existingassertSystemDataIsWritableprecedent). Regenerated spec artifacts ride along. Declaration adopted onsys_activityonly in this PR (the measured gap); the five sibling objects are a deliberate follow-up.Session:
session_61f28fb9-8e31-4a6f-b4d8-10f749a092d2— same claim, same branch.feat(seed): declared pointer-pair resolution — seeds can address an A…
os-zhuang commentedon Aug 23, 2026
{ "issue": 11339, "status": "done", "branch": "claude/issue-11339-seed-activity-pointer", "pr": "https://github.com/objectstack-ai/objectstack/pull/11388", "premise_still_valid": true, "summary": "Shipped the declared pointer-pair shape (issue option 1, carrying option 3's refusal half). New spec carrier: a text field may declare referenceVia:'<sibling>' (ADR-0052 §5 ActivityPointer), refused at parse on non-text or combined with reference, and at ObjectSchema.create when the sibling is undeclared or self-referential. The seed loader (load step 4.5 + the shared per-record resolution loop) resolves a declared pointer per ROW through the object the sibling column names — same externalId probes, in-memory map and pass-2 deferral static lookups use — and refuses an unresolvable or un-addressable pointer loudly (counted, success:false, error-level log, record dropped where no pass can heal it) instead of storing the literal that attaches to nothing. sys_activity adopts both pairs (record_id via object_name, source_id via source_object), so the shipped console filter {object_name, record_id} now matches seeded rows. Premise verified and SHARPENED on origin/main: the type gate sits in buildDependencyGraph as described, sys_activity is declared in packages/plugins/plugin-audit/src/objects/sys-activity.object.ts, no declaration carrier existed anywhere in spec (xRef is flow-config only), and the pair idiom is platform-wide (6 objects) — which is what justified the declared carrier over a one-off. The spec-surface widening was flagged on the issue before editing, per the claim's condition. Changeset declared minor, deliberately: no working behaviour is lost (verbatim-stored pointers matched nothing; internal-id-shaped values still pass through). Four-axis analysis is in the PR body. Undeclared pairs keep today's verbatim behaviour; sibling-object adoption is #11386; no in-repo seed targets sys_activity, so no existing dataset changes behaviour.", "tests": "All quoted verdicts re-verified at final head b9af5410a (earlier full runs at the parent tree differ only by the test file's import spelling + the double-contract ledger; the ratchet family re-ran at head). metadata-protocol: exit 0, 'Test Files 139 passed | 2 skipped (141)' / 'Tests 1892 passed | 10 skipped (1902)' incl. 9 new tests in seed-loader-pointer-pair.test.ts. spec (full suite): 414 files passed after the liveness classification; field.test.ts gained accept/refuse/create/describe pins. plugin-audit: exit 0, 'Test Files 19 passed (19)'. check:generated exit 0 — all 14 artifact gates ✓ (authorable-surface, api-surface, docs, liveness regenerated + committed). Reverse verification from the committed state with trap-restore: mutation (if(false) at load step 4.5) confirmed on disk by grep (marker=1, original text=0); predicted 7 RED / 2 GREEN in the new file, observed exactly 'seed-loader-pointer-pair.test.ts (9 tests | 7 failed)' with uuid-passthrough and undeclared-pair staying green; restore leg grep-confirmed (marker=0, original=1) and full suite re-ran exit 0. No rebuild leg applies: the test imports ./seed-loader.js through vitest's transform, not across a dist boundary. One unrelated 5s-timeout flake (reference-sites.derivation.test.ts purity test) red only in the ablated run, green in all three non-ablated runs. Gate sweep per scripts/pm/dispatch-gates.mjs (no paths passed; derived from this repo at 8c64eb591): every derived + convention-triggered family green locally, incl. durability-log-level, cross-package-test-inputs, engine-double-contract (ledger learned the new pinned double via --write), where-matcher, i18n ('9 package(s) — all bundles in sync'), type-check-debt ('re-measure: OK — 33 ledger entr(ies) ... none above its recorded number', exit 0, after spelling the new test's import NodeNext-clean to keep the frozen 63), liveness ('every governed-type property ... classified', exit 0), nul-bytes ('OK ... no raw ASCII control bytes'). Host limitations declared, both unrelated to the diff: scripts/pm/os-verify-lock.sh prints 'VERDICT lock-unusable' on this macOS (flock absent) so heavy commands ran unserialised rather than hand-rolling a lock; check:objectui-changeset's self-test needs mapfile (bash 4) and fails exit 127 inside its fixture on stock macOS bash 3.2 — CI runs both on Linux.", "open_questions": [], "out_of_scope_findings": [ "filed as #11386: adopt referenceVia on the five sibling pointer pairs (sys_audit_log, sys_approval_request, sys_record_share, sys_share_link, sys_automation_run) — per-object measurement required, since declaring flips that object's seed accept/reject contract; cross-linked to #5180, which concerns the same family from the delete-cascade side and may want this same carrier.", "not filed, noted only: mode 'upsert' accepted against managedBy 'append-only' (raised in the card's option 3 context) — reasoned not-a-defect: seed replay idempotence depends on upsert matching (isNoOpReplay), and append-only governs derived API affordances (reconcileManagedApiMethods), not system seed writes; a refusal there would need its own measurement.", "noted for PM: hotcrm#1260's guard comment says to delete that test deliberately when this lands — that deletion belongs to the hotcrm seat unblocking hotcrm#1258, together with re-seeding the demo interactions the card originally asked for." ] }os-zhuang commentedon Aug 23, 2026
PM review (contract tier) — ACCEPT. PR #11388. Reviewed against the branch's spec/loader/adopter sources and GitHub; merge on CI green.
The shape decision is the right one, and the justification is measured
Option 1 (declared pointer-pair carrier) carrying option 3's refusal half. The deciding measurement: the
(type-column, id-column)pair idiom is platform-wide — six objects — which is what elevates a declaredreferenceViacarrier over asys_activityone-off. A one-off would have fixed the exemplar and left five siblings lying the same way; the carrier makes the sixth impossible to add wrong.The spec-surface widening was flagged on the issue before any spec edit, exactly as the claim conditioned. Governed faces untouched (0 files under
docs/adr/orskills/— the ADR-0052 §5 citation is a reference, not an edit), so this merges through the normal queue.Contract face verified in source, not in the report
field.zod.ts):referenceViaon non-text refused at the authoring seam ("where the fix is one keystroke away"); mutually exclusive with staticreference— a static and a per-record target contradict, and the schema says so. The describe text states what declaring enforces today and what it does not add ("no referential integrity or $expand behavior") — the honesty clause that keeps the carrier from over-promising.object.zod.ts): undeclared or self-referential sibling refused.seed-loader.tsstep 4.5): per-row resolution through the object the sibling column names, riding the same externalId machinery static lookups use; unresolvable pointer refused loudly (counted,success:false, error log, record dropped) — never stored verbatim. The silent half of Seeds cannot address an ActivityPointer: sys_activity.record_id is plain text, so a packaged app can load timeline rows that attach to nothing #11339 is dead.sys_activitydeclares both pairs (record_idviaobject_name,source_idviasource_object), so the shipped console filter{object_name, record_id}matches seeded rows — the exact query measured failing in hotcrm#1258.Compatibility reasoning accepted
Changeset
minor, deliberately: undeclared pairs keep today's verbatim behaviour; on the one adopted object, the writes that now refuse are precisely those that matched nothing — no working behaviour is lost. The upsert-vs-append-only question was reasoned, not waved off: seed replay idempotence depends on upsert matching (isNoOpReplay), andmanagedBy: 'append-only'governs derived API affordances, not system seed writes. That is a mechanism argument, on the record, falsifiable later.Verification shape
Reverse verification predicted 7 red / 2 green and observed exactly that — with the two greens (uuid-passthrough, undeclared-pair) being the compatibility cases that must survive the ablation; a 9/9-red result would have meant the compat surface was never pinned. Full gate sweep per
dispatch-gates.mjsgreen including the generated-artifact family (liveness/authorable-surface regenerated and committed — the declared key is classified, not a silent bag entry).Follow-ups, verified on GitHub
finding, open, unassigned): adopt on the five sibling objects, per-object measurement required since each adoption flips that object's seed accept/reject contract. Correctly NOT folded into this PR.Blocked-by:unlock scan, on the release that carries this.test(spec): the second data/ file group's test titles state each cite…