Skip to content

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

Closed

Description

@os-zhuang

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_email action bodies — every action-created interaction would get two rows. That is the two-producer conflict hotcrm#702 already ruled against on crm_forecast.
  • A post-boot script driving the shipped actions over HTTP works, but then demo:reset && dev alone 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)

  1. Make the pointer pair resolvable in seeds. Teach the loader that object_name + record_id (and source_object + source_id) form a polymorphic reference, so record_id accepts the target's natural key and resolves it against the object named in the sibling column. Declared = enforced; no consumer change.
  2. A polymorphic reference field type that sys_activity adopts, 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.
  3. 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_activity is lifecycle.class: 'telemetry' with retention: { 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_activity declares no owner_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

  1. self-assigned this
    on Aug 23, 2026
  2. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    Claim: direct-dispatch (hotcrm seat acting on the platform card under maintainer instruction)
    Session: session_61f28fb9-8e31-4a6f-b4d8-10f749a092d2
    Branch: claude/issue-11339-seed-activity-pointer
    Worktree: objectstack-issue-11339
    File surface: packages/metadata-protocol/src/seed-loader*.ts and the sys_activity declaration site (re-derived by the dev; expected under packages/platform-objects/ or packages/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 log on seed-loader.ts shows 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:blocked on this card (Route A ruled).

  3. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    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_activity x2 pairs, sys_audit_log, sys_approval_request, sys_record_share, sys_share_link, sys_automation_run.trigger_*), all Field.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 to reference, plus its contradiction checks) and packages/spec/src/data/object.zod.ts (create-time sibling-existence assertion, following the existing assertSystemDataIsWritable precedent). Regenerated spec artifacts ride along. Declaration adopted on sys_activity only 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.

  4. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor
    {
      "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."
      ]
    }
  5. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    ContributorAuthor

    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 declared referenceVia carrier over a sys_activity one-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/ or skills/ — 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

    • Parse-time refusals (field.zod.ts): referenceVia on non-text refused at the authoring seam ("where the fix is one keystroke away"); mutually exclusive with static reference — 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.
    • Create-time refusals (object.zod.ts): undeclared or self-referential sibling refused.
    • Loader (seed-loader.ts step 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.
    • Adoption: sys_activity declares both pairs (record_id via object_name, source_id via source_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), and managedBy: '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.mjs green 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

  6. removed their assignment
    on Aug 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions