This is an ObjectStack application: business objects, views, automations and security are declared as TypeScript metadata, not hand-written CRUD.
- Entry point:
objectstack.config.ts(defineStack()) - Spec package:
@objectstack/spec(Zod-first schemas and types) - Namespace:
duly· object prefix:duly_· app id:ai.objectstack.duly
Several agents work this repo at once. The shared checkout has its HEAD switched and its tree reset under you, silently clobbering uncommitted work. A feature branch on the shared checkout is not enough.
git worktree add ../duly-issue-<n> -b claude/issue-<n>-<slug> main
cd ../duly-issue-<n> && pnpm installMake every edit there.
refs/stash lives in the common .git directory, so every worktree shares
one LIFO stack. Two agents stashing in their own worktrees push and pop the
same stack — your pop restores the other agent's work and reports success.
Use instead, all inside your own worktree:
git diff > /tmp/wip.patch && git checkout -- <paths> # then: git apply /tmp/wip.patch
git commit -am wip # then: git reset --soft HEAD~1Assign the issue to yourself and post a claim comment naming your branch, as the first action of the task. Re-read the comments before writing code: an earlier claim from a different session means the issue is taken, whatever the assignee field says.
Metadata mistakes fail silently at runtime: a dangling widget binding
renders an empty chart, a flow left out of its barrel never fires, an
unregistered hook never runs. pnpm validate catches most of them — including
a bare field reference on every predicate surface but one. Rule 4 below says
which one, and why it is the exception.
pnpm validate # same gates as build, no artifact — the fast inner loop
pnpm typecheck
pnpm testNever report a metadata change as done until pnpm validate passes.
| Context | Convention | Example |
|---|---|---|
| Config keys (TS props) | camelCase |
maxLength, defaultValue |
| Machine names (data values) | snake_case |
duly_task, period_key |
| Metadata type names | singular | 'view', 'flow' |
| File names | {name}.{type}.ts |
task.object.ts, duly.app.ts |
role is a reserved word in the platform vocabulary — the author-time linter
rejects it. Use position (distribution), permission_set (capability),
business_unit (hierarchy), or a domain word.
-
Zod first. Types derive from schemas via
z.infer<>. -
Barrels — and never edit
objectstack.config.ts. Every metadata directory is pre-created and already wired into the config, empty ones included. Add your entry to your OWNsrc/<type>/index.tsnamed array (dulyFlows,dulyJobs, …); the config is the one file every parallel task would otherwise collide on. A file not in its barrel is dead metadata that type-checks and never runs. The collections are named arrays rather thanObject.values(barrel)because on an empty namespaceObject.valueshas nothing to infer from and resolves against the keyed branch ofMetadataCollectionInput, which makesnameoptional and fails the assignment. -
Hooks are registered in
defineStack({ hooks }), not collected from the objects barrel. An unregistered*.hook.tsnever runs. -
Predicates are CEL —
record.<field>, never bare<field>, on every surface: object validation rules, fieldrequiredWhen/ conditional rules, actionvisible/disabled, sharing rules, hook conditions, and flow node and edge conditions.What decides whether the gate catches you is SCOPE, not which surface you are on. An expression is evaluated either with the record bound as the
recordnamespace and nothing at top level (record scope), or with the record's fields additionally flattened into top-level variables (flattened scope).validatejudges a bare identifier only in the first.Record-scoped surfaces —
validateenforces this, and the failure really isnull. Object validation rules, field conditional rules, actionvisible/disabled, sharing rules and hook conditions all bind the record as a namespace only, so a bare name binds nothing, the expression evaluates to null, and the rule or action silently never fires.validatecatches it before it ships. Measured on@objectstack/cli17.2.0, mutatingduly_task'sskip_needs_reasonrule tostatus == "skipped" && …exits 1 with:object 'duly_task' · validation 'skip_needs_reason': bare reference
status— a formula/validation expression binds the record as therecordnamespace, not at top level, sostatusresolves to nothing and the expression silently evaluates to null. Writerecord.status.A misspelt qualified read is caught everywhere, flows included:
record.needs_colectionin a flow condition givesunknown field `needs_colection` on `duly_assignment` — did you mean `needs_collection`?.Flow node and edge conditions are the one exception — no gate at all. They run in flattened scope, where a bare name may genuinely be a flow variable (a loop iterator, a
get_recordoutput, an assignment target), so the platform'scollectBoundRecordReadsdeliberately never reads a bare identifier as a record reference. The same predicate writtenstatus == "dispatched"in a flow start condition passesvalidatewith exit 0.And in a flow the failure is not
nulleither. A barestatusthere resolves — to the flattened field, or to a same-named flow variable that was seeded first and shadows it. That shadowing case is the subtler bug: the predicate reads correctly and silently means something else. When a name resolves to nothing the engine throws (ADR-0032 §1c: no silent fallback — a non-okresult is a real fault, not a false condition). So on this one surface the outcomes are "silently means something else" and "loud runtime fault", never the quiet null of the record-scoped surfaces above.Filed upstream as objectstack-ai/objectstack#14089. Until it lands,
test/flow-predicates.test.tsis a repo-local stopgap covering exactly the unguarded surfaces — flow node and edge conditions, walked overdulyFlows, withdulyJobswalked as a tripwire — and it is written to be deleted when #14089 ships, not maintained. -
Never store what you can filter. No
is_late, nois_overdue, nois_open. A stored flag needs a writer that runs every midnight; a formula field is virtual and a filter naming one silently matches nothing. Askstatusanddue_datedirectly — they are stored and indexed.The one exception, and its exact boundary (#52). A value may be stored when it is written once, at the instant it becomes knowable, and never recomputed — a historical fact, not a maintained flag.
duly_taskcarries four:completed_atandcompleted_late(stamped at completion),visible_fromandlate_after(due_date + duty.grace_days, stamped at dispatch).The test is not "is it derivable" — every one of these is derivable. It is what happens the day nobody writes it:
maintained flag ( is_late)write-once fact ( completed_late)needs a writer at midnight yes — and lies the day it does not run no changes when config changes yes, retroactively and silently no, by design wrong answer looks like a stale flag nobody can see is stale nothing: it is what was true then So the question to ask of a candidate column is: would a second write ever have to happen? If yes — if it tracks the clock, or has to be refreshed when a related record is edited — it is the banned shape, whatever it is called. If no, and it records what was true at a moment that has passed, it is the same category as
completed_atand it may be stored.Two obligations come with using this exception, both load-bearing:
readonly: trueand ONE writer, which is the hook or the planner. A column a caller can set is a column that drifts.- Say what the write-once cost is, where the reader will hit it. For
late_after: editing a duty'sgrace_daysdoes not move the deadline on tasks already dispatched. That surprises whoever has just corrected a misconfiguration, so thelateview's comment namesduly_catalog_sync— the action that already exists to replay duty edits — as the place a recompute would belong. An undocumented denormalisation is the drift this rule is really about.
-
sharingModelis mandatory and fail-closed. Unset means private, and the publish linter errors on it (ADR-0090 D1/D7). State it deliberately. -
A hierarchy scope REQUIRES
requires: ['hierarchy-security']. Omitting it is an author-time hard error, not a silent fallback.readScope/writeScope'own_and_reports' | 'unit' | 'unit_and_below'(ADR-0057) are the hierarchy scopes, resolved by@objectstack/security-enterprise.objectstack.config.tsalready declares the capability — you should not need to touch it — and you author the scopes normally. Build no application-level fallback.Grant one without the declaration and
defineStackrefuses to load, invalidateHierarchyScopeCapability. It is not the config file being fussy; it is the platform closing the exact hole this rule used to tell you to live with. The platform's own words:A stack that uses one MUST declare
requires: ['hierarchy-security']; otherwise the open runtime would silently fail closed to owner-only (the metadata would lie, ADR-0049). This makes that an authoring-time error instead.Because the check runs inside
defineStack(), an undeclared scope takesvalidate,buildand every test that imports the config — so the symptom is the whole suite going red at once, not one assertion.Declaring it does NOT fail an open-edition boot. Measured on this checkout with
@objectstack/security-enterprisenot installed:validate,typecheck,testandbuildall exit 0, the kernel logsBootstrap complete, andvalidateprints exactly one warning naming the package that provides the capability. That warning is the expected state of this repo — do not silence it. The only two ways to make it go away are installing the enterprise package (deliberately not done here) and deleting the declaration (which puts the hard error back).In this open-edition checkout the scopes then resolve to owner-only, so a manager view shows you only your own rows. That is the edition, not a bug to chase; verifying manager visibility for real needs an enterprise runtime and a populated business-unit tree.
⛔
orgis not a hierarchy scope and passes the check with no declaration at all. That is the trapdoor: it is the nearest thing to hand when a scope will not load and you want "some visibility for managers", and it discloses every row in the tenant. Depth for managers isunit_and_below. Narrower than intended is visible and fixable;orgis neither. -
English is the source language. Every authored label gets an
enentry;zh-CNis hand-translated. Do not hard-code display text in a hook or flow. -
Metadata first — a handler is the last resort, not the first. This is an ObjectStack application. Objects, views, flows, jobs, datasets, permission sets and actions are the primary tools; a hand-written handler is what you reach for when none of them can express the thing. Before writing a handler, check whether the platform already has a declarative way to do it. The spec is on disk —
@objectstack/specships its Zod sources — and the answer is usually a key you have not read yet. If the platform genuinely cannot express it, file an issue againstobjectstack-ai/objectstackand say so on the card. Do not quietly write around the gap. A workaround in application code is how a platform gap becomes permanent and invisible: it works, nobody upstream ever hears about it, and the next application writes the same workaround from scratch.test/flow-predicates.test.tsis what a declared workaround looks like — labelled a stopgap, pointing at objectstack#14089, and built to be deleted.
No Duly permission set can. Saving a view customization overlay is gated on
the platform capability manage_metadata, which src/security/permission-sets.ts
grants to nobody — not duly_member, not duly_manager, not duly_admin. The
overlay a column-header click persists (PUT /api/v1/meta/view/<name>, stored in
sys_metadata as scope: platform, owner: null, org-scoped, replacing the
declared view for the whole organisation) is reachable only by a platform
admin — isPlatformAdmin: true, which on a dev box is the pnpm demo account
and in a deployment is whoever holds admin_full_access.
Measured on @objectstack/rest 17.2.0 against a live pnpm demo, with three
self-registered accounts each bound to exactly one Duly set through
sys_user_permission_set (binding verified live by A/B: a bound holder reads
duly_task 200, an unbound account 403):
| Caller | PUT /api/v1/meta/view/duly_task.default |
|---|---|
| anonymous | 401 UNAUTHENTICATED |
duly_member |
403 FORBIDDEN — requires the `manage_metadata` capability |
duly_manager |
403 FORBIDDEN — same |
duly_admin |
403 FORBIDDEN — same |
| platform admin | 200 — Saved customization overlay (org=…) |
sys_metadata stayed at total: 0 across every refused attempt. The compound
twin door (PUT /api/v1/meta/duly_task/views/default) and the reset door
(DELETE) carry the identical gate — checked, because a gate on one door and
not its twin is the usual bypass. The one metadata-ish store a member can
write is sys_user_preference, which requires user_id and is per-person by
construction; the org-wide sys_view_definition answers 403.
⛔ Do not "harden" this by denying manage_metadata in
src/security/permission-sets.ts. systemPermissions is an additive list
with no deny form, so the entry would not be enforcement — it would be a
declared-and-unenforced key of exactly the kind ADR-0049 exists to remove, sitting
in the one file whose credibility depends on every line in it being live.
What remains true and is not a Duly bug: for a caller who does hold the
capability, an ordinary sort click still persists an org-wide overlay carrying the
entire view definition, with no save gesture and no UI indication — so an
administrator demoing the app can freeze duly_task.default at the shipped
definition without knowing it. That is a platform affordance question for
objectstack-ai/objectui, deliberately left unfiled here; see #84 for the
measurement a report would need.
Branch claude/issue-<n>-<slug> off main, in your own worktree. Push the
branch and open a draft PR referencing the issue. All four gates must be
green in the PR before you hand it back:
pnpm validate && pnpm typecheck && pnpm test && pnpm buildA duly_task cannot be created in done by an ordinary caller. completed_at is
readonly, the beforeInsert hook stamps only last_update_at, so
completed_at_required_when_done refuses the row:
ValidationError: A completed task must carry a completion timestamp. (code: VALIDATION_FAILED)
That is correct and it stays. Tasks are dispatched open; completion is a
later transition. The refusal is the product working.
Historical rows — a seed, an import, a fixture — are written from a system
context instead. { context: { isSystem: true } } is the whole mechanism: it
exempts a write from the readonly strip. It is the same leg dispatch.job.ts
already uses, and the leg the platform's own seed loader uses
(SeedLoaderService.SEED_OPTIONS = { isSystem: true, skipTriggers: true, seedReplay: true }).
It takes two passes, and the second one is the half that gets forgotten.
| Column | How you seed it |
|---|---|
completed_at |
carried on the insert, from a system context |
last_update_at |
a second pass in mode: 'update' — an insert can never carry it |
Why last_update_at is different: beforeInsert stamps it unconditionally, and
lifecycle hooks do run on the seed path — skipTriggers suppresses
record-change automation, not hooks — so a system insert's value is overwritten
with the boot clock. The beforeUpdate leg deliberately does not stamp on an
administrative write, so a second write carrying only last_update_at lands
untouched. Ergonomics card: #63.
Skip that second pass and there are no stalled rows — open tasks last touched 14+ days ago — so the "Not moving" view is empty on a freshly seeded demo. It is the one signal the product claims is its most valuable, and nothing errors: the seed reports success and the view is simply blank.
Three datasets, in this order, in defineStack({ data }):
// 1. sys_user FIRST. duly_task.owner is a user lookup and the seed loader
// resolves it as a NATURAL KEY against sys_user.name. A bare id that matches
// no sys_user row does not resolve, and because owner is required the whole
// task row is refused — "Owner is required" — with the loader also logging
// the unresolved reference. Measured: without this, nothing seeds.
{ object: 'sys_user', externalId: 'name', mode: 'insert',
records: [{ name: 'seed_user_alice', username: 'seed_user_alice', /* … */ }] },
// 2. The history itself. completed_at rides along on the insert.
{ object: 'duly_task', externalId: 'subject', mode: 'insert',
records: [{ subject: '…', owner: 'seed_user_alice', source: 'catalog',
status: 'done', completed_at: '2026-05-04T09:00:00.000Z' }] },
// 3. The SAME rows again, matched on externalId, to backdate the clock.
{ object: 'duly_task', externalId: 'subject', mode: 'update',
records: [{ subject: '…', last_update_at: '2026-07-18T08:00:00.000Z' }] },Prefer relative instants (Date.now() - 45 * DAY) over literals for anything
whose age is the point: a hard-coded date stops being "stalled" as the repo ages.
Both directions are pinned by test/seed-history.test.ts — the system write
succeeds and an ordinary caller's identical write is still refused. Keep both
halves. A test that only proved "the seed may" would quietly turn readonly: true
into decoration.
The insert-path readonly strip is a boundary guard: it lives in
MetadataProtocolService.createData, which is what @objectstack/rest — and so
REST, OpenAPI and MCP — writes through. engine.insert applies no readonly
strip at all (the update path is different: that strip is inside ObjectQL and is
isSystem-gated). So a direct data.insert from in-process code can set a
readonly column at insert with no context and no warning.
Filed upstream as objectstack-ai/objectstack#14147, and pinned as a tripwire in
test/seed-history.test.ts so it goes red when the platform closes it. Practical
consequence for authoring here: do not read an engine-level test as proof that a
column is protected — assert against protocol.createData when the refusal is the
thing you care about. Flows are unaffected today because assignment.flow.ts
declares runAs: 'system', which is elevated regardless.
{ context: { isSystem: true } } exempts a write from the readonly strip. That
is what makes the section above work, and it is the whole mechanism — which
means it exempts every readonly column, not just the ones this app owns.
So it is a licence to write history, and it is not a licence to write a column
another component maintains.
readonly: true marks two different things, and only one of them fights
back.
| What the flag means | Example | How you seed it |
|---|---|---|
| A component owns this column and recomputes it. There is a source table, and a hook derives this value from it. | sys_user.primary_business_unit_id — plugin-sharing recomputes it from sys_business_unit_member.is_primary (ADR-0057 addendum D12) |
Write the source. Seed the rows the projection is computed from and let the platform derive the column. |
| Nothing recomputes it; its maintenance is just somebody else's surface. No hook, no source table — the flag keeps it off the ordinary edit form. | sys_user.manager_id — readonly because org-structure maintenance is its own admin surface (ADR-0092); completed_at, for that matter |
Write it directly, from a system context, exactly as above. There is nothing else to write. |
The first kind fails in a way no gate catches, because it does not fail at
write time at all. A direct write lands, reads back correct, and survives every
boot for as long as the source table stays empty — the recompute has simply
never had an input. The day anything writes one source row for that record, the
hook fires and replaces your value with whatever the source says, or clears it.
Nothing errors. Measured on #74: twelve users carried a hand-written
primary_business_unit_id and sys_business_unit_member had 0 rows, for as
long as the seed had existed.
How to tell which kind you are looking at, before you write it:
- Read the column's
descriptionin@objectstack/platform-objects. The first kind says so — "a denormalised projection of …, maintained by …. Do not edit directly; set it via …". Take that sentence literally; it is not style. - Grep the platform for a writer:
grep -rn "<column>\s*:" packages/pluginsin the monorepo. The first kind has one (anengine.updatein the plugin that owns it) and a hook that calls it. The second kind has only reads. - If it has a writer, find what that writer reads from. That table is what your seed writes.
⛔ Do not generalise from one column to its neighbours. manager_id and
primary_business_unit_id sit next to each other in the same Organization
field group with the same readonly: true, and they are opposite cases. "Both
are readonly, so treat both the same" is precisely the inference that produced
the defect.
These are the product, not preferences. If a task seems to require breaking one, stop and raise it on the issue instead of working around it.
- One task has exactly one owner.
- Dispatch is idempotent on
(duty, owner, period_key). - Standing duties never generate tasks.
- Managers do not enter status. Assigning is their only write.
- Completion never requires evidence, a note, or a percentage.
duly_log_entrynever enters a metric, a ranking, or a comparison.- Item counts are never ranked or compared, anywhere in the UI.