The scene below is a composite staging failure.
Night-shift incidents often start in unsigned schema diffs.
Checkout webhooks began rejecting every signed order payload.
Staging had merged a late cleanup from an agent branch.
A free-lane draft marked the tax_id field optional.
Production clients still treated that field as required.
Billing pipelines stalled while operators hunted the unsigned diff.
The agent self-check had reported a clean local parse.
The same model had written and then graded that patch.
This piece is a when-not-to field guide.
It targets contract-class edits rather than comment churn.
Free-lane drafts may exist, but they must not own the lock.
What counts as a contract
Call a file a contract when other systems must obey it.
Shared schemas outlive the chat that proposed them.
Silent field drops then become multi-service incidents.
Treat the following artifacts as contract-class changes:
- OpenAPI files and JSON Schema documents under version control
- Agent tool parameter schemas that callers must satisfy
- Database migrations and generated ORM model files
- Protobuf, Avro, or GraphQL schema definition files
- Webhook payload contracts consumed by outside partners
- IAM condition documents that authorize destructive writes
A README tweak is not a contract-class change.
A log format experiment inside one service is not either.
Dropping a required field is always a contract-class change.
Public debate now mixes vibe coding with actual engineering work.
That mix is harmless inside a scratch branch.
It becomes costly once external callers depend on the bytes.
The failure pattern
A free-tier draft often looks locally correct.
The agent prints a green self-check beside the diff.
That check usually uses the same model that wrote it.
Circular grading hides missing consumer constraints.
Callers outside the repo still send the previous shape.
Those callers break without a version or deprecation window.
Support then sees invalid payload errors with no owner.
The schema lock was never assigned to a human reviewer.
Rollback becomes another generation instead of a pinned revert.
Red flags
Refuse promotion when any flag below is true.
- The diff touches contracts, migrations, or tool schema paths.
- The origin label equals free-tier or scratch-server.
- The same model authors the patch and the tests.
- A required key becomes optional without a version bump.
- Enum values disappear without a documented deprecation window.
- The apply step has no checksum of the previous lock.
- Rollback means generate another patch rather than revert.
- The change alters authentication, money, or deletion semantics.
- Tests only assert parse success and skip consumer fixtures.
- The free server process also runs the apply job.
- The free-lane patch increments version to self-approve.
Any single flag is enough to block promotion.
Two flags mean the branch should return to scratch.
Decision table
Keep this table next to the merge box.
| Change class | Free-lane draft | Trusted lock | Apply from free origin |
|---|---|---|---|
| Scratch comment in a temp file | Allowed | Optional | Allowed |
| Local unit test rename | Allowed | Optional | Allowed |
| Tool JSON Schema required-field edit | Draft only | Required | Refused |
| SQL migration or ORM model change | Draft only | Required | Refused |
| OpenAPI path or method removal | Draft only | Required | Refused |
| Webhook status enum shrinkage | Draft only | Required | Refused |
| Incident timeline or audit record | Refused | Required | Refused |
| Secret, token, or IAM policy bytes | Refused | Required | Refused |
Draft means the bytes never leave a scratch branch.
Lock means a human or trusted job signs the digest.
Refused means the apply gate must exit non-zero.
A fail-closed apply gate
The Node.js gate below is an unexecuted proposal.
Operators should trial it on staging branches first.
It refuses contract paths from a free or unknown origin.
It also compares file digests against a committed lockfile.
A new digest must already sit in the pending map.
Unsigned bytes never reach the apply job.
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const CONTRACT_PREFIXES = [
'contracts/',
'migrations/',
'tools/schema/',
'openapi.yaml',
'openapi.yml'
];
const FREE_ORIGINS = {
'free-tier': true,
'scratch-server': true,
'best-effort-model': true
};
function sha256(filePath) {
const buf = fs.readFileSync(filePath);
return crypto.createHash('sha256').update(buf).digest('hex');
}
function isContractPath(rel) {
const normalized = rel.split(path.sep).join('/');
return CONTRACT_PREFIXES.some(function (g) {
return normalized === g || normalized.indexOf(g) === 0;
});
}
function loadChangedFiles() {
const raw = process.env.CHANGED_FILES || '';
return raw.split('|').map(function (s) {
return s.trim();
}).filter(Boolean);
}
function main() {
const origin = process.env.PATCH_ORIGIN || 'unknown';
const lockPath = process.env.SCHEMA_LOCK || 'schema.lock.json';
const changed = loadChangedFiles();
if (!fs.existsSync(lockPath)) {
console.error('missing schema.lock.json');
process.exit(2);
}
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const contractHits = changed.filter(isContractPath);
if (contractHits.length === 0) {
console.log('no contract-class paths; apply gate skipped');
process.exit(0);
}
if (FREE_ORIGINS[origin] || origin === 'unknown') {
console.error('refuse: free or unknown origin on contract paths');
console.error(contractHits.join('\n'));
process.exit(3);
}
for (let i = 0; i < contractHits.length; i += 1) {
const rel = contractHits[i];
if (!fs.existsSync(rel)) {
continue;
}
const digest = sha256(rel);
const expected = lock.files && lock.files[rel];
if (!expected) {
console.error('refuse: contract file missing from lock: ' + rel);
process.exit(4);
}
const pending = lock.pending && lock.pending[rel];
if (digest !== expected && digest !== pending) {
console.error('refuse: unsigned digest for ' + rel);
console.error('got ' + digest);
process.exit(5);
}
}
console.log('contract gate passed');
process.exit(0);
}
main();
Save that file as contract_gate.js beside the lockfile.
Keep the script in source control with the schemas.
Treat changes to the gate as contract-class as well.
Example lockfile, labeled as a stub only:
{
"version": 1,
"files": {
"contracts/order.schema.json": "REPLACE_WITH_SHA256"
},
"pending": {}
}
Replace the stub digest before any real run.
Do not commit empty hashes in a production repo.
A small signer writes pending digests after human review:
#!/usr/bin/env node
'use strict';
const fs = require('fs');
const crypto = require('crypto');
const lockPath = process.env.SCHEMA_LOCK || 'schema.lock.json';
const file = process.argv[2];
if (!file) {
console.error('usage: node sign_pending.js <contract-file>');
process.exit(1);
}
const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
const digest = crypto
.createHash('sha256')
.update(fs.readFileSync(file))
.digest('hex');
lock.pending = lock.pending || {};
lock.pending[file] = digest;
fs.writeFileSync(lockPath, JSON.stringify(lock, null, 2) + '\n');
console.log('pending ' + file + ' ' + digest);
Commands to exercise the gate
Label every patch at creation time, not later.
Do not infer origin after the files already changed.
export PATCH_ORIGIN=free-tier
export SCHEMA_LOCK=schema.lock.json
export CHANGED_FILES="$(git diff --name-only origin/main | tr '\n' '|')"
node contract_gate.js
echo $?
A free-lane contract diff should exit with code 3.
That refusal is the entire purpose of the gate.
Promote only after a lock owner writes pending digests.
node sign_pending.js contracts/order.schema.json
export PATCH_ORIGIN=human-lock
node contract_gate.js
echo $?
Keep consumer fixtures beside the schema files.
Run those fixtures against every pending digest.
mkdir -p tests/fixtures/orders
npx --yes ajv-cli validate -s contracts/order.schema.json -d tests/fixtures/orders/*.json
If fixtures fail, drop the pending digest immediately.
Do not repair fixtures with the same free model.
A minimal order fixture might look like this stub:
{
"order_id": "ord_example_not_real",
"customer_id": "cus_example_not_real",
"tax_id": "XX-0000000",
"total_cents": 1099
}
Keep several fixtures, including one that must fail.
A schema that accepts every blob is not a lock.
Reproducible test plan
Run these steps on a throwaway clone.
Do not point the gate at a live apply runner.
- Commit a valid
schema.lock.jsonon the main branch. - Branch, set
PATCH_ORIGIN=free-tier, and edit a contract file. - Export
CHANGED_FILESfromgit diff --name-only origin/main. - Run
node contract_gate.jsand expect exit code 3. - Restore origin to
human-lockwithout a pending digest. - Expect exit code 5 for the unsigned digest.
- Write the pending digest as a separate human commit.
- Rerun the gate and expect exit code 0.
- Run consumer fixtures; fail the branch if any fixture breaks.
- Record the final digest into
filesand clearpending.
Skip step ten while the branch is under review.
Write the digest into files only after merge.
The plan needs no production traffic and no vendor quota claims.
It only proves the origin and digest policy.
Better alternatives
Use the free lane for exploration only.
Keep the signed lock on a trusted job or a human.
- Humans write a short RFC before any required-field change.
- A trusted renderer emits code from the signed schema only.
- Consumers publish fixtures the drafting agent cannot modify.
- Ship a v2 document instead of silently dropping fields.
- Revert by checksum pin, not by asking a model to undo.
Additive optional fields can still start as scratch drafts.
Removals and type changes must start on the locked path.
A rename is a delete plus an add, not a cleanup.
Version numbers belong to the lock owner, never the draft.
A free-lane bump of v1 to v2 is still an unsigned contract.
Ship v2 as a new file, then deprecate v1 on a calendar.
A scratch server can host throwaway agents safely.
That host should not run migration apply or cluster apply.
Queue irreversible jobs only on signed, non-free runners.
Exit criteria
Leave the free lane for contract work when any item trips.
- A required field changed without a matching version bump.
- Two consumers failed the same fixture within one week.
- The apply job and the draft job share a host.
- Origin metadata is missing on more than one merge.
- An incident needed a schema revert and none existed.
- The model that wrote the diff also updated the tests.
- Version numbers moved in the same commit as the draft.
After exit, freeze contract paths in CODEOWNERS.
Require a lock signature before merge.
/contracts/ @schema-lock-owners
/migrations/ @schema-lock-owners
/tools/schema/ @schema-lock-owners
Keep the owners list short and awake.
An empty CODEOWNERS file is not a lock.
Where a scratch lane still helps
Drafts still need a cheap place to fail.
That split is the useful part of free-tier access.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
MonkeyCode includes free model access and a free server option.
They fit scratch diffs, prompt trials, and throwaway agent loops.
They are not a home for the lockfile or production apply.
A team can emit a candidate schema on a scratch server.
A human then copies the bytes into a locked branch.
The gate above ignores vendor names and reads origin labels.
Who should not use this approach
Skip this gate in a few narrow cases.
- Solo prototypes with no external contract consumers.
- Schemas that never leave a personal notebook directory.
- Repos without migrations, webhooks, or tool specifications.
- Teams that already require two-person review on every schema file.
Do not use the gate as a substitute for backups.
A refused apply does not restore yesterday's data.
Do not treat the gate as a secret scanner.
It only checks origin labels and file digests.
Limitations
The script trusts the PATCH_ORIGIN environment variable.
A mislabeled job will bypass the whole policy.
The prefix list is incomplete on purpose.
Each team must extend it for local layout.
Checksum equality does not prove semantic safety.
It only proves the lock owner saw those bytes.
Fixture validation misses many behavioral breaks.
Money rounding and timezone shifts still need domain tests.
This article does not publish load numbers or model names.
It does not claim quotas, hardware, or uptime.
Free-tier behavior drifts without notice.
The gate assumes that drift is normal and untrusted.
Field checklist
Print this list next to the merge box.
- Classify the files as contract-class or not.
- Read PATCH_ORIGIN and refuse the unknown label.
- Compare digests against the committed schema lockfile.
- Run consumer fixtures on the pending bytes.
- Version the document instead of dropping required keys.
- Revert by pin, not by another model generation.
If step two or three fails, stop the merge.
Do not negotiate with a free-lane draft.
Contract-class edits should fail closed every time.
Free-lane drafts can stay drafts without apology.
Top comments (0)