feat(linkedin-studio): SB-S3c — cross-silo id-threading + post→analytics assembler [skip-docs]

Hub-side design: the published record now carries the specifics/trends ids
it was built from (additive, omit-empty → byte-backward-compatible), and a
new pure assembler (scripts/brain/src/assemble.ts + `brain assemble`) joins
post↔analytics by normalized title-prefix + date with honest confidence
tiers (high/low/none). Answers the arc's north-star query: which raw
material actually performs? (specific → post → measured analytics).

All four tributaries untouched (analytics READ-only via inlined raw-JSON,
no package import); profile.md grammar untouched (the fact→post link stays
OUT — C-1). The repeatable --specific/--trend ingest flags collect via a
new collectRepeated helper, leaving parseFlags untouched.

TDD: 19 new brain tests (ingest 4 + publish 3 + assemble 8 + cli 4), all
SC1–SC12. brain 113/113, gate 95/0/0, BRAIN_TESTS_FLOOR 94→113,
ASSERT_BASELINE_FLOOR unchanged at 80. Light-Voyage hardened
(brief-review 5 FIX · plan-critic 1 BLOCK+4 MAJOR+4 MINOR · scope-guardian ALIGNED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
This commit is contained in:
Kjell Tore Guttormsen 2026-06-23 20:47:34 +02:00
commit edd3e15ef7
12 changed files with 515 additions and 14 deletions

View file

@ -35,6 +35,14 @@ export interface PublishedRecord {
captured_at: string;
/** `manual` (default) | a future connector token. */
source: string;
/**
* SB-S3c the raw material this post was built from: specifics-bank + trends
* ids (12-hex), threaded hub-side so the postspecifictrendanalytics graph is
* assemblable. Default `[]`; serialized only when non-empty (omit-empty keeps a
* pre-S3c record byte-identical). Validated to 12-hex on parse + at the producer.
*/
specifics: string[];
trends: string[];
/** The verbatim post body — byte-exact, may contain anything (`]`/`|`/newlines/`---`). */
body: string;
}
@ -54,8 +62,36 @@ export function serializePublishedRecord(rec: PublishedRecord): string {
`published_date: ${rec.published_date}`,
`captured_at: ${rec.captured_at}`,
`source: ${rec.source}`,
].join("\n");
return header + SENTINEL + rec.body;
];
// SB-S3c: the raw-material id lines are appended AFTER `source:` and emitted ONLY
// when non-empty — so a pre-S3c record (empty arrays) serializes to the unchanged
// 5-line header, byte-identical (SC2). Body-only `mintContentId` is unaffected.
if (rec.specifics.length > 0) header.push(`specifics: ${rec.specifics.join(",")}`);
if (rec.trends.length > 0) header.push(`trends: ${rec.trends.join(",")}`);
return header.join("\n") + SENTINEL + rec.body;
}
const ID_RE = /^[0-9a-f]{12}$/;
/**
* SB-S3c: read an optional comma-separated 12-hex id list from the header slice.
* Absent key `[]` (a NON-throwing reader NOT `headerScalar`, which throws on a
* missing key and would break every pre-S3c record). A present-but-malformed id
* throws (never silently dropped).
*/
function headerIdList(header: string, key: string): string[] {
const m = header.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, "m"));
if (!m) return [];
return m[1]
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "")
.map((id) => {
if (!ID_RE.test(id)) {
throw new Error(`malformed published record: bad ${key} id ${JSON.stringify(id)}`);
}
return id;
});
}
function headerScalar(header: string, key: string): string {
@ -94,6 +130,8 @@ export function parsePublishedRecord(text: string): PublishedRecord {
published_date: headerScalar(header, "published_date"),
captured_at: headerScalar(header, "captured_at"),
source: headerScalar(header, "source"),
specifics: headerIdList(header, "specifics"),
trends: headerIdList(header, "trends"),
body,
};
}
@ -167,13 +205,27 @@ export function ingestText(opts: {
captured_at: string;
source?: string;
published_date?: string;
specifics?: string[];
trends?: string[];
}): IngestResult {
// SB-S3c producer guard: a malformed raw-material id fails fast HERE (not only on
// a later re-parse). Empty/absent arrays skip validation, so every pre-S3c caller
// (incl. scanInbox) is unaffected.
const validateIds = (ids: string[] | undefined, kind: string): string[] => {
const list = ids ?? [];
for (const id of list) {
if (!ID_RE.test(id)) throw new Error(`ingest: bad ${kind} id ${JSON.stringify(id)}`);
}
return list;
};
const record: PublishedRecord = {
id: mintContentId(opts.body),
provenance: "published",
published_date: opts.published_date ?? opts.captured_at,
captured_at: opts.captured_at,
source: opts.source ?? "manual",
specifics: validateIds(opts.specifics, "specifics"),
trends: validateIds(opts.trends, "trends"),
body: opts.body,
};
return { record, ...writePublished(record) };