🧯 fix: Let Only the Body's Own Prior Declaration Release a Restriction

Self-review found the previous guard was the wrong shape. Gating on "the new
body has a frontmatter block" still released a bag-only restriction whenever the
edited body happened to carry a block, and any key the body reader cannot see
(a quoted `"user-invocable":`) looked like a removal too. Both are instances of
one class: treating the reader's silence as a declaration.

A body edit may now only remove a flag that the STORED body declared, read back
under the same version guard that protects the write. An edit can therefore
release what the author wrote into the file, a skill whose flags were never in
the text keeps them, and a key the reader misses is invisible on both sides of
the comparison — so its blind spots degrade to no-ops instead of silent
releases. The structured-bag contract is untouched: a bag that omits a key still
removes it, which stays the escape hatch for flags the body never had.

Two reader divergences fixed with it, both confirmed against the real modules:
the body scanner now unquotes keys, so `"user-invocable": false` is honored the
way the importer already honored it; and an empty flag value is a placeholder
rather than a malformed boolean even when the line scan finds nothing, which an
indented mapping or a quoted key can cause.

A corpus of 26 frontmatter shapes now runs through both the import path and the
inline-body path with identical columns in 25 — the residual is duplicate keys
differing only in case, where the file is ambiguous by construction (js-yaml
takes the last, the line reader the first) and neither answer can release a
restriction.
This commit is contained in:
Danny Avila 2026-08-04 09:23:33 -04:00
parent 0c5cd2ed84
commit 1d139666ae
4 changed files with 157 additions and 80 deletions

View file

@ -510,6 +510,31 @@ describe('parseFrontmatter', () => {
},
);
it('treats an empty flag value as a placeholder even when the mapping is indented', () => {
/* The line scan is anchored at column zero, so an indented key yields no raw
text to inspect. Judging by the parsed shape keeps a mid-edit placeholder
from being reported as a malformed value. */
const raw = `---\n name: n\n description: d\n user-invocable:\n---\n\nbody`;
const result = parseFrontmatter(raw);
expect(result.userInvocable).toBeUndefined();
expect(result.invalidBooleans).toEqual([]);
});
it('still flags a malformed value when the mapping is indented', () => {
const raw = `---\n name: n\n description: d\n user-invocable: tru\n---\n\nbody`;
expect(parseFrontmatter(raw).invalidBooleans).toEqual(['user-invocable']);
});
it.each(['"user-invocable"', "'user-invocable'"])('reads a %s quoted key', (key) => {
const raw = `---\nname: n\ndescription: d\n${key}: false\n---\n\nbody`;
const result = parseFrontmatter(raw);
expect(result.userInvocable).toBe(false);
expect(result.frontmatter).toEqual({ 'user-invocable': false });
});
it('resolves flags when the whole frontmatter mapping is indented', () => {
const raw = `---\n name: n\n description: d\n user-invocable: false\n---\n\nbody`;
const result = parseFrontmatter(raw);

View file

@ -100,6 +100,18 @@ function stripInlineComment(value: string): string {
return value.trim();
}
/** Strip one layer of matching YAML quotes, if present. */
function unquoteScalar(value: string): string {
if (
value.length >= 2 &&
((value[0] === '"' && value[value.length - 1] === '"') ||
(value[0] === "'" && value[value.length - 1] === "'"))
) {
return value.slice(1, -1).trim();
}
return value;
}
function normalizeFrontmatterKeys(frontmatter: Record<string, unknown>): Record<string, unknown> {
return Object.entries(frontmatter).reduce<Record<string, unknown>>((acc, [key, value]) => {
const normalizedKey = key.toLowerCase();
@ -135,8 +147,17 @@ function parseBoolean(value: unknown, rawValue?: string): boolean | undefined {
return undefined;
}
/**
* Whether a flag line carries no value yet `user-invocable:`, a
* comment-only value, or empty quotes. All are mid-edit states rather than
* malformed booleans, so they are treated as if the key were absent.
*/
function hasBooleanPlaceholder(rawValue?: string): boolean {
return rawValue !== undefined && stripInlineComment(rawValue).length === 0;
if (rawValue === undefined) {
return false;
}
const stripped = stripInlineComment(rawValue);
return stripped.length === 0 || unquoteScalar(stripped).length === 0;
}
type ResolvedBooleanFlag = { value?: boolean; invalidKey?: string };
@ -154,11 +175,18 @@ function readPresentBooleanFlag(
key: string,
): ResolvedBooleanFlag {
const rawValue = getRawFrontmatterValue(block, key);
const value = parseBoolean(getCaseInsensitive(frontmatter, key), rawValue);
if (value === undefined && !hasBooleanPlaceholder(rawValue)) {
return { invalidKey: key };
const parsedValue = getCaseInsensitive(frontmatter, key);
const value = parseBoolean(parsedValue, rawValue);
if (value !== undefined) {
return { value };
}
return { value };
if (rawValue === undefined) {
/* No line to cross-check the key is quoted, or the mapping is indented.
Judge by the parsed shape instead: an empty value is a placeholder, while
anything else present is a value that failed to read as a boolean. */
return parsedValue == null ? {} : { invalidKey: key };
}
return hasBooleanPlaceholder(rawValue) ? {} : { invalidKey: key };
}
function resolveBooleanFlag(

View file

@ -1000,7 +1000,13 @@ describe('Skill CRUD methods', () => {
expect(viaName?.disableModelInvocation).toBeUndefined();
});
it('keeps bag-only restrictions when a body edit declares no frontmatter block', async () => {
it.each([
['a body with no frontmatter block', 'Still a plain body, just edited.'],
[
'a body whose frontmatter block never mentioned the flags',
'---\nname: bag-only-restricted\ndescription: A demo skill.\ncategory-ish: x\n---\n\nEdited.',
],
])('keeps bag-only restrictions across %s', async (_label, newBody) => {
/* The legacy / API-only shape: flags live in the bag, and the body never
declared them. A body edit there is not a statement about invocation
channels, so it must not lift the restriction silently opening a
@ -1027,7 +1033,7 @@ describe('Skill CRUD methods', () => {
const updated = await methods.updateSkill({
id,
expectedVersion: 1,
update: { body: 'Still a plain body, just edited.' },
update: { body: newBody },
});
expect(updated.status).toBe('updated');
@ -1036,12 +1042,12 @@ describe('Skill CRUD methods', () => {
expect(reloaded?.disableModelInvocation).toBe(true);
});
it('releases a bag-only restriction once the body declares a frontmatter block without it', async () => {
/* The counterpart: an explicit block that omits the key IS a declaration,
which is what makes the release path in the UI work. */
it('releases a bag-only restriction when an explicit frontmatter bag drops it', async () => {
/* Structured callers keep the long-standing contract: a bag that omits the
key removes it. That is the escape hatch for flags the body never had. */
const legacy = await Skill.create({
name: 'bag-only-released',
description: 'Flags set through the API, then declared away.',
description: 'Flags set through the API, then dropped through the API.',
body: 'Plain body, no frontmatter.',
frontmatter: { 'user-invocable': false },
author: owner._id,
@ -1056,13 +1062,7 @@ describe('Skill CRUD methods', () => {
);
const id = (legacy._id as mongoose.Types.ObjectId).toString();
await methods.updateSkill({
id,
expectedVersion: 1,
update: {
body: '---\nname: bag-only-released\ndescription: A demo skill.\n---\n\nBody.',
},
});
await methods.updateSkill({ id, expectedVersion: 1, update: { frontmatter: {} } });
const reloaded = await methods.getSkillByName('bag-only-released', [legacy._id]);
expect(reloaded?.userInvocable).toBeUndefined();
@ -1079,6 +1079,20 @@ describe('Skill CRUD methods', () => {
expect(skill.userInvocable).toBe(false);
});
it('reads a flag written with a quoted key', async () => {
/* The importer honors a quoted key because js-yaml unquotes it; the body
reader has to strip the quotes itself or the same file would behave
differently depending on how it reached the server. */
const { skill } = await methods.createSkill(
makeSkillInput({
name: 'quoted-key',
body: '---\nname: quoted-key\ndescription: A demo skill.\n"user-invocable": false\n---\n\nBody.',
frontmatter: undefined,
}),
);
expect(skill.userInvocable).toBe(false);
});
it('reads flags from a frontmatter block that is indented as a whole', async () => {
const { skill } = await methods.createSkill(
makeSkillInput({

View file

@ -774,16 +774,6 @@ type BodyAlwaysApplyResult =
/** Body-derived state for every boolean flag mirrored onto a column. */
type BodyFlagResults = Record<SkillBooleanColumn, BodyAlwaysApplyResult>;
type BodyFlagScan = {
/**
* Whether the body carried a YAML frontmatter block at all. A body without
* one declares nothing, so it must not be read as declaring the *absence* of
* a restriction see `updateSkill`.
*/
hasBlock: boolean;
flags: BodyFlagResults;
};
const BODY_FLAG_BY_KEY = new Map<string, SkillBooleanFlag>(
SKILL_BOOLEAN_FLAGS.flatMap((flag) =>
[flag.key, ...flag.aliases].map((key) => [key.toLowerCase(), flag] as const),
@ -856,6 +846,18 @@ function readContinuedFlagValue(
return null;
}
/** Strip one layer of matching YAML quotes, if present. */
function unquoteScalar(value: string): string {
if (
value.length >= 2 &&
((value[0] === '"' && value[value.length - 1] === '"') ||
(value[0] === "'" && value[value.length - 1] === "'"))
) {
return value.slice(1, -1).trim();
}
return value;
}
function readBodyFlagValue(rawValue: string): BodyAlwaysApplyResult {
/* Strip the YAML inline comment BEFORE unquoting a line like
`always-apply: "true" # note` has both, and handling whole-line quoting
@ -864,13 +866,7 @@ function readBodyFlagValue(rawValue: string): BodyAlwaysApplyResult {
if (value === '') {
return { status: 'absent' };
}
if (
value.length >= 2 &&
((value[0] === '"' && value[value.length - 1] === '"') ||
(value[0] === "'" && value[value.length - 1] === "'"))
) {
value = value.slice(1, -1).trim();
}
value = unquoteScalar(value);
if (value === '') {
return { status: 'absent' };
}
@ -910,7 +906,7 @@ function readBodyFlagValue(rawValue: string): BodyAlwaysApplyResult {
* value YAML continues onto the following line is read from there rather than
* being treated as an unwritten placeholder.
*/
function extractBooleanFlagsFromBody(body: string | undefined): BodyFlagScan {
function extractBooleanFlagsFromBody(body: string | undefined): BodyFlagResults {
const results: BodyFlagResults = {
alwaysApply: { status: 'absent' },
userInvocable: { status: 'absent' },
@ -918,12 +914,12 @@ function extractBooleanFlagsFromBody(body: string | undefined): BodyFlagScan {
};
const block = extractBodyFrontmatterBlock(body);
if (block === null) {
return { hasBlock: false, flags: results };
return results;
}
const lines = block.split('\n');
const baseIndent = findMappingIndent(lines);
if (baseIndent === null) {
return { hasBlock: true, flags: results };
return results;
}
const canonical = new Map<SkillBooleanColumn, BodyAlwaysApplyResult>();
const aliased = new Map<SkillBooleanColumn, BodyAlwaysApplyResult>();
@ -937,7 +933,7 @@ function extractBooleanFlagsFromBody(body: string | undefined): BodyFlagScan {
if (colon === -1) {
continue;
}
const key = trimmed.slice(0, colon).trim().toLowerCase();
const key = unquoteScalar(trimmed.slice(0, colon).trim()).toLowerCase();
const flag = BODY_FLAG_BY_KEY.get(key);
if (!flag) {
continue;
@ -960,11 +956,11 @@ function extractBooleanFlagsFromBody(body: string | undefined): BodyFlagScan {
results[flag.column] = resolved;
}
}
return { hasBlock: true, flags: results };
return results;
}
function extractAlwaysApplyFromBody(body: string | undefined): BodyAlwaysApplyResult {
return extractBooleanFlagsFromBody(body).flags.alwaysApply;
return extractBooleanFlagsFromBody(body).alwaysApply;
}
/**
@ -1258,7 +1254,7 @@ export function createSkillMethods(
and for the derivation cascades. Avoids parsing the same YAML
frontmatter block twice per create. */
const bodyScan = data.body !== undefined ? extractBooleanFlagsFromBody(data.body) : undefined;
const bodyAlwaysApply = bodyScan?.flags.alwaysApply;
const bodyAlwaysApply = bodyScan?.alwaysApply;
const issues: ValidationIssue[] = [
...validateSkillName(data.name),
...validateSkillDescription(data.description),
@ -1266,7 +1262,7 @@ export function createSkillMethods(
...validateSkillDisplayTitle(data.displayTitle),
...validateSkillFrontmatter(data.frontmatter),
...validateAlwaysApply(data.alwaysApply),
...validateBodyDerivedColumns(data.frontmatter, bodyScan?.flags),
...validateBodyDerivedColumns(data.frontmatter, bodyScan),
];
/* Body-level `always-apply:` only needs to be well-formed when a
higher-precedence source won't override it (see
@ -1324,7 +1320,7 @@ export function createSkillMethods(
*/
const bodyDerived: { userInvocable?: boolean; disableModelInvocation?: boolean } = {};
for (const column of BODY_DERIVED_COLUMNS) {
const resolved = resolveBodyDerivedColumn(column, derived, bodyScan?.flags);
const resolved = resolveBodyDerivedColumn(column, derived, bodyScan);
if (resolved !== undefined) {
bodyDerived[column] = resolved;
}
@ -1597,7 +1593,7 @@ export function createSkillMethods(
Avoids parsing the same YAML frontmatter block twice per update. */
const bodyScan =
update.body !== undefined ? extractBooleanFlagsFromBody(update.body) : undefined;
const bodyAlwaysApply = bodyScan?.flags.alwaysApply;
const bodyAlwaysApply = bodyScan?.alwaysApply;
const issues: ValidationIssue[] = [];
if (update.name !== undefined) issues.push(...validateSkillName(update.name));
if (update.description !== undefined)
@ -1608,7 +1604,7 @@ export function createSkillMethods(
if (update.frontmatter !== undefined)
issues.push(...validateSkillFrontmatter(update.frontmatter));
if (update.alwaysApply !== undefined) issues.push(...validateAlwaysApply(update.alwaysApply));
issues.push(...validateBodyDerivedColumns(update.frontmatter, bodyScan?.flags));
issues.push(...validateBodyDerivedColumns(update.frontmatter, bodyScan));
/* Body-level `always-apply:` only needs to be well-formed when a
higher-precedence source won't override it (see
`resolveAlwaysApplyFromInput` for precedence). Rejecting a typo
@ -1636,6 +1632,32 @@ export function createSkillMethods(
}
const Skill = mongoose.models.Skill as Model<ISkillDocument>;
/**
* A body edit may only release an invocation-mode flag that the SKILL.md
* text itself was carrying, so the stored body is read to see what it
* declared before. Without this, a skill whose flags live only in the
* frontmatter bag the legacy shape `backfillDerivedFromFrontmatter`
* serves, and what setting flags through the API alone produces would
* have a restriction silently lifted by an edit that never mentioned it.
* It also makes the body reader's blind spots harmless: a key it cannot
* see is invisible on both sides of the comparison, so the flag is left
* alone instead of being wrongly released.
*
* Safe under optimistic concurrency: the write below still requires
* `version: expectedVersion`, so a body that changed between this read and
* the update fails as a version mismatch rather than acting on stale text.
*/
const storedBodyFlags =
update.body !== undefined && update.frontmatter === undefined
? await Skill.findById(id)
.select('body')
.lean()
.then((doc) =>
doc
? extractBooleanFlagsFromBody((doc as unknown as { body?: string }).body)
: undefined,
)
: undefined;
const setPayload: Record<string, unknown> = {};
const unsetPayload: Record<string, ''> = {};
if (update.name !== undefined) setPayload.name = update.name;
@ -1665,46 +1687,34 @@ export function createSkillMethods(
/**
* Boolean invocation-mode columns follow whichever source the update
* carries: a key in the structured bag wins, then the SKILL.md body's own
* frontmatter (the only signal the UI edit flow sends). When neither
* declares the flag but the update did supply one of those sources, the
* column is unset back to its schema default removing
* `disable-model-invocation:` from a SKILL.md re-enables model invocation,
* mirroring how a removed `always-apply:` line stops auto-priming. Updates
* touching neither `frontmatter` nor `body` leave the columns alone.
* frontmatter (the only signal the UI edit flow sends).
*
* A body carrying no frontmatter block at all declares nothing and so does
* not count: skills whose flags live only in the bag (the legacy shape
* `backfillDerivedFromFrontmatter` exists for, and what a caller setting
* flags through the API alone produces) would otherwise have a restriction
* silently lifted by an unrelated body edit. Losing a restriction that way
* is worse than keeping one a step longer, so it takes an explicit
* frontmatter block with the key removed from it to release.
* Removal is the delicate half. A bag that omits a key removes it, which is
* the long-standing contract for callers sending structured frontmatter. A
* body may only remove what that same body used to declare compared
* against `storedBodyFlags` so an edit can release a restriction the
* author wrote into the file, while a skill whose flags were never in the
* text keeps them. When a body-driven removal happens, the bag's copy of
* that key goes too, otherwise `backfillDerivedFromFrontmatter` would read
* the restriction straight back over the unset column on the next lookup.
*/
const declaresColumns =
update.frontmatter !== undefined ||
(update.body !== undefined && bodyScan?.hasBlock === true);
for (const column of BODY_DERIVED_COLUMNS) {
const resolved = resolveBodyDerivedColumn(column, bagDerived, bodyScan?.flags);
const resolved = resolveBodyDerivedColumn(column, bagDerived, bodyScan);
if (resolved !== undefined) {
setPayload[column] = resolved;
} else if (declaresColumns) {
unsetPayload[column] = '';
continue;
}
}
/**
* A body-only edit rewrites the SKILL.md text without sending a bag, so the
* stored bag would keep flag keys the edited text no longer declares and
* `backfillDerivedFromFrontmatter` reads those back over an unset column on
* the next lookup, resurrecting a restriction the author just removed.
* Hand flag authority to the columns resolved above by clearing the bag's
* copies; the SKILL.md body still carries the declarations, so nothing is
* lost, and the next save that does send a bag repopulates them.
*/
if (update.frontmatter === undefined && bodyScan?.hasBlock === true) {
for (const flag of SKILL_BOOLEAN_FLAGS) {
for (const key of [flag.key, ...flag.aliases]) {
unsetPayload[`frontmatter.${key}`] = '';
}
if (update.frontmatter !== undefined) {
unsetPayload[column] = '';
continue;
}
if (storedBodyFlags?.[column].status !== 'valid') {
continue;
}
unsetPayload[column] = '';
const flag = SKILL_BOOLEAN_FLAGS.find((candidate) => candidate.column === column);
for (const key of flag ? [flag.key, ...flag.aliases] : []) {
unsetPayload[`frontmatter.${key}`] = '';
}
}
if (update.category !== undefined) setPayload.category = update.category;