commit b7f7a1195135bf9ec0f122f820985c4bbb582aa0
parent 0241df1b65171770f5c1d1bfe198a04e74b9e69e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 16:06:02 -0400
tags: a site layer has no rules, rather than rules that do nothing
A rule is evaluated once at index time over records SHARED by every site that
carries the channel, so a rule written at the site layer has no per-site record
to put its hit in — it can only ever LOOK like it worked. The store's header
already said so while mergeTagDefs quietly appended one anyway, "harmless on a
published def": harmless is exactly how it reads to an operator who then waits
for a build that will never tag anything.
So the site coercion drops rules outright — on read, so a hand-edited
sites/<id>/tags.json cannot smuggle one in, and on write, so neither can a
caller — and mergeTagDefs loses its append branch. One place, covering every
writer, next to the label rule that is the layer's other asymmetry.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 71 insertions(+), 20 deletions(-)
diff --git a/common/lib/curatedTags.test.ts b/common/lib/curatedTags.test.ts
@@ -226,7 +226,40 @@ test("sanitizeTagsConfig is idempotent", () => {
// ─── mergeTagDefs ───
-test("mergeTagDefs overlays presentation fields and appends site rules", () => {
+test("the site layer carries no rules, on read and on write", () => {
+ const raw = {
+ tags: [
+ {
+ id: "eva-collab",
+ rules: [{ id: "site", kind: "metadata", pattern: "extra" }],
+ },
+ {
+ id: "site-only",
+ label: "Site only",
+ rules: [{ id: "r1", kind: "caption", pattern: "x" }],
+ },
+ ],
+ };
+ const site = sanitizeTagsConfig(raw, { layer: "site" });
+ // Not emptied — DROPPED: no `rules` key at all, so what a site writes has
+ // none and nothing downstream is ever handed a rule that cannot fire.
+ for (const def of site.tags) {
+ assert.equal(Object.prototype.hasOwnProperty.call(def, "rules"), false);
+ }
+ // The same coercion runs on read, so a hand-edited site file cannot smuggle
+ // one back in.
+ assert.deepEqual(
+ sanitizeTagsConfig(JSON.parse(JSON.stringify(site)), { layer: "site" }),
+ site,
+ );
+ // And the corpus layer is untouched by this: the identical input keeps them.
+ assert.deepEqual(
+ (sanitizeTagsConfig(raw).tags[0].rules ?? []).map((r) => r.id),
+ ["site"],
+ );
+});
+
+test("mergeTagDefs overlays presentation fields and cannot add a rule", () => {
const merged = mergeTagDefs(DEFS, [
{
id: "eva-collab",
@@ -235,6 +268,9 @@ test("mergeTagDefs overlays presentation fields and appends site rules", () => {
color: "#b48ead",
order: 9,
hidden: true,
+ // A rule reaching the merge at all means someone bypassed the site
+ // coercion; it is still not appended, because a site rule can never tag
+ // a record and "harmless on a published def" is how it looks like it can.
rules: [{ id: "site", kind: "metadata", pattern: "extra", enabled: true }],
},
]);
@@ -246,7 +282,7 @@ test("mergeTagDefs overlays presentation fields and appends site rules", () => {
assert.equal(collab.hidden, true);
assert.deepEqual(
(collab.rules ?? []).map((r) => r.id),
- ["meta", "site"],
+ ["meta"],
);
// Untouched fields survive, and the global input is not mutated.
assert.equal(collab.group, "eva");
diff --git a/common/lib/curatedTags.ts b/common/lib/curatedTags.ts
@@ -222,11 +222,21 @@ function coerceTagDef(raw: unknown, layer: TagLayer): CuratedTagDef | null {
// override", and inventing one here would rename the tag on that site, since
// mergeTagDefs applies every field the overlay carries.
const label = trimmedString(r.label) ?? (layer === "site" ? undefined : id);
- const rules = Array.isArray(r.rules)
- ? r.rules
- .map((rule, i) => coerceRule(rule, i))
- .filter((rule): rule is CuratedTagRule => rule !== null)
- : [];
+ // THE SECOND FIELD THE LAYERS TREAT DIFFERENTLY: a site layer carries no
+ // rules at all, and one written there is dropped — on read AND on write, so
+ // it can never reach a file, a merge or a published def.
+ //
+ // A rule is evaluated once at index time, over records SHARED by every site
+ // that carries the channel. There is no per-site record for a per-site hit to
+ // live in, so a site rule could only ever LOOK like it worked. Dropping it in
+ // the coercion is the one place that covers every writer. Rules bind from
+ // transcripts/tags.json alone; promote one there to make it fire.
+ const rules =
+ layer === "site" || !Array.isArray(r.rules)
+ ? []
+ : r.rules
+ .map((rule, i) => coerceRule(rule, i))
+ .filter((rule): rule is CuratedTagRule => rule !== null);
const order =
typeof r.order === "number" && Number.isFinite(r.order)
? r.order
@@ -348,10 +358,13 @@ export function sanitizeTagsConfig(
// Layer a site's tags.json over the corpus one.
//
// For an id the corpus already defines, the site entry is a FIELD-WISE OVERLAY
-// of label/groupLabel/color/order/hidden and may APPEND rules. It can never
-// delete a corpus rule (and assignments do not live at the site layer at all —
-// an assignment is a fact about a video, not a presentation choice). A site id
-// the corpus does not define becomes a full site-only tag.
+// of label/groupLabel/color/order/hidden — presentation, and nothing else. It
+// can never delete a corpus rule, and it cannot add one: coerceTagDef drops
+// rules from a site layer entirely (see there for why a per-site rule cannot
+// tag anything), so there is nothing here to append. Assignments do not live at
+// the site layer at all — an assignment is a fact about a video, not a
+// presentation choice. A site id the corpus does not define becomes a full
+// site-only tag, rule-less like every other site row.
//
// Deliberately NOT mergeAliases' wholesale replacement: aliases are suggestions,
// tags are a shared vocabulary that a site may dress up but not gut.
@@ -375,9 +388,8 @@ export function mergeTagDefs(
if (overlay.color !== undefined) base.color = overlay.color;
if (overlay.order !== undefined) base.order = overlay.order;
if (overlay.hidden !== undefined) base.hidden = overlay.hidden;
- if (overlay.rules && overlay.rules.length > 0) {
- base.rules = [...(base.rules ?? []), ...overlay.rules];
- }
+ // No rule branch: a sanitized site def has none, and the corpus rules on
+ // `base` are left exactly as the corpus wrote them.
}
return out;
}
diff --git a/common/lib/curatedTagsStore.ts b/common/lib/curatedTagsStore.ts
@@ -7,12 +7,15 @@
// site-only tags. See common/lib/curatedTags.ts for the pure model, the
// coercion and mergeTagDefs.
//
-// **A site-layer RULE never tags a record.** mergeTagDefs will append one (it
-// is harmless on a published /tags.json def), but rules are evaluated once at
-// index time over records SHARED by every site that carries the channel —
-// there is no per-site record for a per-site hit to live in. Rules bind only
-// from transcripts/tags.json; promote one there to make it fire. This is why
-// the editor offers no site-side rule editor.
+// **A site-layer RULE never tags a record, so a site layer carries none.**
+// Rules are evaluated once at index time over records SHARED by every site that
+// carries the channel — there is no per-site record for a per-site hit to live
+// in, so a rule written at the site layer could only ever LOOK like it worked.
+// The site coercion therefore DROPS rules outright: on READ, so a hand-edited
+// sites/<id>/tags.json cannot smuggle one in, and on WRITE, so neither can a
+// caller — and mergeTagDefs has no rule branch left to append through. Rules
+// bind from transcripts/tags.json alone; promote one there to make it fire.
+// This is why the editor offers no site-side rule editor.
//
// Unlike aliasesStore there are NO seeded defaults: a fresh install has no
// tags, and an absent global file reads as an empty config.