Projects

/api/projects

Create, read, update, and publish construction projects. Projects own datasheets, standards, files, and members.

Endpoints

GET /api/projects

List projects accessible to the authenticated user, organized by access level. GET /api/projects/my-projects is an alias for this same call — one endpoint, two paths.

Required permission: project:read

GET /api/projects/details/:id

Get detailed project information including address, vertical, status, and stakeholders. For a caller who may edit or publish the project, `draftVersion` is the open draft's version (an opaque string, null when there is no open draft): send it back unchanged as baseVersion on PUT /api/projects/edit/:id and POST /api/projects/:id/publish.

Required permission: project:read

Parameters
NameInTypeDescription
formatquerystring"verbose" (default, all fields) or "compact" (id/name/vertical/status/revision code/city/state). Invalid values return 400. HTTP CALLERS ONLY — the assistant path does not read this parameter, so sending it here changes nothing about the response and is not a way to alter or shrink a result. The assistant already receives a reduced projection of about a dozen fields, and unlike compact it keeps `summary`.
shapequerystring"v2" returns the consolidated, audience-gated project view (header, standards, datasheets and members in one payload) instead of the flat project-header lookup this endpoint otherwise serves. Recipients see the audience-filtered form of that payload. HTTP CALLERS ONLY — the assistant path does not read this parameter, so sending it here changes nothing about the response and is not a way to alter or shrink a result.
draftquerystring"1" or "true" assembles the project's working DRAFT through the v2 renderer. Only meaningful with shape=v2, and AUTHOR-ONLY: a caller without project:draft:edit gets 403, never a filtered payload, because a draft is never visible to recipients. HTTP CALLERS ONLY — the assistant path does not read this parameter, so sending it here changes nothing about the response and is not a way to alter or shrink a result.
revisionIdqueryintegerRender a specific published revision instead of the newest one you may read. A revision your access does not reach is refused (404) rather than downgraded, and a historical one additionally requires project:revision:view. Honoured by BOTH shapes: with shape=v2 it selects the whole payload, and on the flat project-header lookup it selects the header — name, summary, address, status and revision code come from that revision, not from the project's current one. Omitting it serves the newest revision your access reaches, which for a scope-limited recipient is NOT the project's current header. HTTP CALLERS ONLY — the assistant path does not read this parameter, so sending it here changes nothing about the response and is not a way to alter or shrink a result.

GET /api/projects/:id/revisions

List project revisions, newest first. Includes draft revisions if you have project:draft:view permission. Each revision has is_draft field. current_revision_id names the current published revision (null when there is none or it is outside your access); the first row may be an unpublished draft, so do not use list position to find the current revision.

Required permission: project:revision:view

Optional permission (unlock additional behaviour): project:draft:view

GET /api/projects/:projectId/seat-provenance

Where the caller's editing seat on this project comes from: funding is one of 'own_staff' (internal), 'own_seat' (their own firm's seat, BYOS), 'trial' (with trialEndsAt), 'sponsored' (the owning company's collaborator seat) or 'none' (viewer floor — viewing is free). Also carries lapsedFromRole (set when a trial expiry downgraded the caller to viewer and they have not yet acted), pendingSponsorshipRequest, trialSpent and canRequestSponsorship. Auth only: the payload is strictly about the caller.

POST /api/projects/:projectId/sponsorship-requests

Ask the project's owning company to sponsor the caller's editing with a collaborator seat. External collaborators only (internals are told to ask about seats); requires existing project access and no current authoring role. Body may carry requestedRole (an editor/admin-grade project role; defaults to the role a trial expiry took away, else ProjectEditor). One pending request per person per project — a duplicate is 409. The owning company's admins are notified unconditionally and decide from the company seat register. Session only: API keys and connected apps are refused (Tier 1).

Parameters
NameInTypeDescription
requestedRolebodystringEditor- or admin-grade project role to request (e.g. 'ProjectEditor'). Optional.

POST /api/projects/:id/lead

Appoint or transfer the PROJECT LEAD (the ProjectOwner role — the person who manages the project on SynC, NOT the Development Owner stakeholder field and NOT the owning company). Requires company:administer on the company that OWNS the project; a client recorded as Development Owner has no authority here. The appointee must be a member of that company, and the appointment takes a seat (or a collaborator seat), so it can be refused 409 SEAT_LIMIT_REACHED / SLOT_LIMIT_REACHED. Transfer is atomic: the outgoing lead loses the role and the incoming one gains it together. Appointing on a project with no current lead is valid — that is what GET /api/companies/:id/projects-awaiting-lead lists. Re-appointing the current lead is a no-op (changed: false). Session only: API keys and connected apps are refused (Tier 1).

Required permission: company:administer

Parameters
NameInTypeDescription
userIdbodyintegerThe user to make Project Lead. Required.

POST /api/projects/:projectId/seat-provenance/acknowledge-lapse

Acknowledge a lapsed trial on this project ("continue as a viewer"): clears the lapse marker so the three-doors prompt stops re-presenting. The viewer floor itself needs no action — it is already granted. Session only: API keys and connected apps are refused (Tier 1).

GET /api/projects/:id/upgrade-status

What in this project is BEHIND its parent — the one read that answers "have any of our standards been updated?". Reports three INDEPENDENT layers, which are not the same question: datasheetsBehind (a datasheet behind the revision the PROJECT PINS for its standard — the only layer that blocks publishing), standardsBehind (the project works to an older revision than the repository has — advisory, never blocks; moving it is a deliberate step, and the way to take it is POST /api/projects/:id/standard-revisions, which changes which revision the project works to WITHOUT touching a datasheet. Bringing the datasheets built on that standard into line with the new revision is a SEPARATE act, POST /api/standards/:standardId/batch-upgrade, and it is what the publish gate measures. A datasheet is an instance of a standard and follows it, never the reverse), and repositoryBehindWiki (the standard was adopted from an older wiki EDITION than the wiki has since adopted — this is the layer a user means by "has the wiki updated this?"). Also returns `checked` ({standards, datasheets}) as the denominator and `upToDate`. Defaults to the draft revision if there is one, otherwise the current published revision. Each repositoryBehindWiki row also carries `heldRevoked` (the held wiki edition has been withdrawn under a moderation notice), `heldRevocationLevel`, `replacementMarked`, `upgradeAvailable`, `offeredAdoptedRevisionId` and `offeredIsMarkedReplacement`. A revoked holding is ALWAYS reported, and is offered the moderator-marked replacement edition when there is one — a revoked edition is never offered as the upgrade. A row with `heldRevoked: true` and `upgradeAvailable: false` is a standard with no way forward yet: it blocks publishing, and the fix is to remove or swap the standard. Each row also carries `holdingScope` ("standard" while a moderator hold covers the whole wiki standard, otherwise null), `upgradeClearsHold` (false when such a hold is open AND a revision is offered) and `upgradeNote`. Under a whole-standard hold the offered revision is still a legitimate upgrade, but it does NOT clear the hold, which covers every revision until a moderator lifts it: never tell the user that upgrading fixes it. Quote `upgradeNote` when it is set.

Required permission: project:read

Parameters
NameInTypeDescription
revisionIdqueryintegerSpecific project revision ID (optional)

GET /api/projects/:id/upgrade-plan

What every datasheet in the open draft needs in order to follow the revision its standard is held at, in one read. Per held standard: `target` (the held revision, { id, code }) and `datasheets`, each with `id`, `name`, `needsUpgrade`, `currentStandardRevisionId`, and four counts taken from the plan GET /api/datasheet/:id/upgrade-plan returns with toRevisionId set to the held revision: `conflictCount` (fields that need a decision), `additions`, `removals`, and `modifications` (fields that move to another element or change value). The counts are 0 for a datasheet already on the target. A plan that cannot be built comes back as `planError` on its row, with counts of 0. For the full plan of one datasheet, call GET /api/datasheet/:id/upgrade-plan. Only the draft can be reconciled, so this takes no revisionId and answers 409 when the project has no open draft.

Required permission: project:draft:edit

GET /api/projects/:id/standard-revision-options

Which revisions of its standards this project may move to. Per standard: the revision it currently works to (heldRevisionId / heldRevisionCode) and every eligible revision, each with `isHeld` and `isCurrent`. Eligible means published (never a working copy) and, for a standard adopted from the wiki, an edition that has not been revoked. EVERY eligible revision is offered, including ones BETWEEN the held revision and the newest — the act is bidirectional, so there is no basis for narrowing the set in one direction only. Each standard also carries `bringCurrentTo` (the revision id a "bring this up to date" change should send, or null when there is none) and `excludedReason` (why it cannot be brought current, when that is the case).

Required permission: project:draft:edit

Parameters
NameInTypeDescription
standardIdqueryintegerNarrow to one standard (optional).

POST /api/projects/:id/standard-revision-preview

What a proposed Change Revision would cost, BEFORE it is made. Per standard: fromRevisionCode, toRevisionCode, direction ("forward" or "backward"), revisionsBetween, datasheetsAffected (datasheets that would be out of sync with the project) and datasheetsWithConflicts (those holding values the target revision will not accept). Plus `aggregate` across the whole set, which is what a single bulk confirmation shows.

Required permission: project:draft:edit

Request body (JSON)

Required

Take toStandardRevisionId from GET /api/projects/:id/standard-revision-options — a target that is not an eligible revision of that standard is 409, and a standard the project does not hold is 404. A BACKWARD direction is an ordinary answer, not an error: it means the target is earlier than what the project works to now, and the datasheets already reconciled forward will need to be brought back before the project can publish. Say so naming BOTH revision codes; a bare count implies an ordering a revision code does not carry.

POST /api/projects/:id/standard-revisions

Change which revision of one or more standards this project works to. One transaction: either every change lands or none does, under one shared batchId, with an optional rationale recorded against all of them. Returns { batchId, projectRevisionId, applied, excluded }. A standard already on the revision named is reported in `excluded` and the rest of the batch still applies — one such entry never refuses the call.

Required permission: project:draft:edit

Request body (JSON)

Required
Optional

WRITES NO DATASHEET. This sets what the project works to and nothing else; bringing the datasheets into line is a separate act (POST /api/standards/:standardId/batch-upgrade), and until it is done the project will not publish — which is the existing rule and is correct. Requires an OPEN DRAFT: with none it is 403 naming the draft-create action, and it never opens one on the user's behalf. Direction is not a constraint: moving to an EARLIER revision is supported, warned about, and never refused. A working copy, or a revision of another standard, is 409. A target equal to the revision already held is NOT: that entry is excluded and reported, and only a call where EVERY entry is a no-op is refused. Preview it with POST /api/projects/:id/standard-revision-preview first so the user sees the datasheet consequence before approving. BRING-EVERYTHING-CURRENT: when the user asks to bring a project up to date, this is ONE call covering every standard that is behind — take each `bringCurrentTo` from the options read, preview the whole set once, and propose a single card with the aggregate counts. Do NOT decline because the request is broad: the approval card is the confirmation, and forty standards is one act rather than forty. Do NOT decline because the datasheets will need conflict decisions either — this call writes no datasheet, so nothing about their state can block it. State how many go out of sync and offer the reconciliation as the next step.

GET /api/projects/:id/datasheets

List all datasheets for a project revision, each with its `upgrade_status` (current | needs_upgrade | no_revision). `needs_upgrade` means a newer standard revision EXISTS in the repository — advisory only; it does not block publishing. For the project-wide answer across standards AND datasheets, use GET /:id/upgrade-status instead. Defaults to draft revision if available, otherwise current published.

Required permission: project:read

Parameters
NameInTypeDescription
revisionIdqueryintegerSpecific project revision ID (optional)
searchQueryquerystringOptional case-insensitive substring filter on the datasheet NAME only (a substring match; it does NOT search requirement text, values, or descriptions, so send a name fragment like "MCC-2", never a question or a requirement phrase). USE IT whenever you are after specific datasheets rather than the whole list: an unfiltered list on a large project can exceed the context budget and be TRUNCATED, and what you receive then looks exactly like a complete list. A "truncated: true" marker in the result means you have not seen everything — re-issue with this filter rather than answering from what came back. To search what the standards actually REQUIRE (not what they are called), use GET /api/standards/search-content instead.

GET /api/projects/:id/standards

List standards associated with a project revision, with category, library, and datasheet count.

Required permission: project:read

Parameters
NameInTypeDescription
revisionIdqueryintegerSpecific project revision ID (optional)
searchQueryquerystringOptional case-insensitive substring filter on the standard NAME only (a substring match; it does NOT search requirement text, values, or descriptions, so send a name fragment like "switchgear", never a question or a requirement phrase). USE IT whenever you are after specific standards rather than the whole list: an unfiltered list on a large project can exceed the context budget and be TRUNCATED, and what you receive then looks exactly like a complete list. A "truncated: true" marker in the result means you have not seen everything — re-issue with this filter rather than answering from what came back. To search what the standards actually REQUIRE (not what they are called), use GET /api/standards/search-content instead.

GET /api/projects/:projectId/standards/:standardId/integrated-content

Get standard elements with matched datasheet values in a single call. Reduces round-trips for requirement queries.

Required permission: standard:read

Parameters
NameInTypeDescription
standardIdpathintegerThe STANDARD's numeric id (from the project standards list) — NOT a datasheet instance id. For per-field datasheet flags like drawing_ref, read the datasheet instance instead (GET /api/datasheet/:id).
searchQueryquerystringKeyword filter on element content
revisionIdqueryintegerProject revision ID for historical data
formatquerystringResponse format: "verbose" (default, all fields) or "compact" (essential fields only, reduces payload size). Invalid values return 400.

POST /api/projects/export/:id

Produce the full JSON export of a project revision (the same payload the UI download button produces): project header, the revision’s section list (heading path + scope statement, in document order), and every datasheet with its recorded element values and the section it is filed under. The payload is LARGE — read it to answer questions or summarize it, never paste it back wholesale. To actually SEND an export to a third party (manufacturer, contractor), tell the user to use the Export button on the project page: the download is the delivery mechanism, and this action cannot attach or transmit a file.

Required permission: project:export

Request body (JSON)

Optional

datasheetIds: array of datasheet instance ids to limit the export to (omit or empty = every datasheet). isVerbose: false/omitted (default) exports the chosen values only — compact; true additionally emits every template element with its options, default_value, and input_type, which is much larger. Verbosity does NOT change the section list — both forms carry it in full. revisionId: export THAT project revision — the one the user is looking at — instead of the default. It must belong to this project and be one your access reaches, and an unpublished draft additionally needs project:draft:view; anything else is 404. Omit it to keep the default: the current published revision, or the draft when the project has never been published. Every call is recorded in the project export audit log. When the revision is published, the file also carries certificate/signature/content — a SynC-signed content certificate and the canonical payload it attests to, which makes the file verifiable offline. Those three keys are additive; the project/datasheets schema is unchanged.

POST /api/projects/create

Create a new project.

Required permission: project:create

Request body (JSON)

Required
Optional

vertical_id must be one of these project_vertical_types ids, taken verbatim from this list — never guessed or inferred: 1 residential, 2 industrial, 3 water treatment, 4 data center, 5 medical, 6 commercial, 7 mining, 8 oil and gas, 9 agriculture, 10 renewable, 11 marine, 12 aerospace, 13 food and beverage, 14 institutional. The same list is served by GET /api/projects/vertical-types. An id outside 1-14 is rejected. Water, wastewater, and treatment-plant projects are 3 (water treatment) — NOT 4. If none of the 14 fits, omit vertical_id rather than picking the closest number. The response echoes the RESOLVED vertical name that was actually applied; report that back, not what you intended. Stakeholder fields (owner_company_id, engineer_id, prime_contractor_id, sub_contractors) take plain numeric company ids of REAL companies the user named — never guess or invent an id. An omitted owner_company_id defaults to the caller's own company; the result echoes the applied owner_company name.

PUT /api/projects/edit/:id

Partial update of project draft. Only fields included in the body are changed — omitted fields are left untouched. Every field here is the project's header or its Project Team, which only the project's owner or an admin may change: a body carrying any of them needs project:update on this project as well as project:draft:edit, so a project editor is refused (403).

Required permission: project:draft:edit

Optional permission (unlock additional behaviour): project:update

Request body (JSON)

Required
Optional

EDIT CONFLICTS: the header merges per field. If someone changed the draft since your baseVersion, the fields only they changed keep their value, the fields only you changed are written, and the response says `merged: true` with `mergedChanges: [{ field, editedBy, editedAt }]`. A field you both changed to different values is 409 `code: "EDIT_CONFLICT"`, `metadata.reason: "fields"`, `metadata.conflicts: [{ field, base, mine, theirs, editedBy, editedAt }]`, and nothing is written; re-read the project and ask the user which value to keep. `reason: "draft_closed"` means the draft was published or discarded. The response carries `version` for the next save and `header`, the draft as stored. A missing baseVersion is 400 BASE_VERSION_REQUIRED. vertical_id must be one of these project_vertical_types ids, taken verbatim from this list — never guessed or inferred: 1 residential, 2 industrial, 3 water treatment, 4 data center, 5 medical, 6 commercial, 7 mining, 8 oil and gas, 9 agriculture, 10 renewable, 11 marine, 12 aerospace, 13 food and beverage, 14 institutional. The same list is served by GET /api/projects/vertical-types. An id outside 1-14 is rejected. Water, wastewater, and treatment-plant projects are 3 (water treatment) — NOT 4. If none of the 14 fits, omit vertical_id rather than picking the closest number. The response echoes the RESOLVED vertical name that was actually applied; report that back, not what you intended. Stakeholder fields take plain numeric company ids of REAL companies the user named — never guess or invent an id. REQUIRES AN OPEN DRAFT: this change lands in the project's current unpublished draft. If the project has no open draft the call is rejected outright — check GET /api/projects/:id/revisions (a revision with is_draft true) and, if there is none, propose POST /api/projects/:id/draft/create first, then make this call. Opening a draft is its own user-approved change; never publish or revert a project to bring a draft into existence.

POST /api/projects/:id/draft/create

Create a new draft from the current published revision.

Required permission: project:draft:create

Request body (JSON)

Optional

ALWAYS send draft_name and draft_summary, in the words of whoever asked for the work. The draft name is how the revision is identified in the project history for good, and the summary is the record of WHY it was opened — a draft with neither is indistinguishable from every other one later. Both are still editable at publish time (revision_name / description on the publish call), so a reasonable name now costs nothing. Do not invent a scope the user did not ask for: name the draft after the change requested, not after what you plan to do beyond it.

GET /api/projects/:id/publish-readiness

The publish gate's content blockers for the project's open draft, read before publishing: every specification that has datasheet fields but no datasheet (`standard_without_datasheet`) and every datasheet with no revision (`datasheet_without_revision`). Returns `{ hasDraft, empty, ready, blockers: [{ kind, id, name }], version }` (`version`: the draft as it stands now). The same check POST /:id/publish runs (its 400 carries the same list as `metadata.blockers`); publish still refuses on its own. Not the whole publish: name, tenant, upgrade, value and moderation checks answer at publish time.

Required permission: project:publish

POST /api/projects/:id/publish

Publish the current draft as an immutable revision.

Required permission: project:publish

Request body (JSON)

Required
Optional

A STALE DRAFT IS REFUSED FIRST: every change to the draft (header, parameter answers, standards and datasheets added, archived, restored, removed or moved, sections, files, coordination checks, datasheet edits) moves its version, and publish answers 409 EDIT_CONFLICT with `metadata.changes: [{ kind, subject, name, action, count, editedBy, editedAt }]` when the draft is not the one you read. Show the user what changed and publish only when they confirm, with the 409 `metadata.currentVersion` as baseVersion. Publishes the project's OPEN DRAFT, so the project must have one — with no open draft there is nothing to publish and the call is rejected. Publishing is irreversible: it freezes the draft as an immutable revision. Only publish when the user explicitly asked for it; never as a step toward some other goal. Refused with 409 and `metadata.code = "PROJECT_HOLDS_REVOKED_STANDARD"` while the draft holds a standard whose wiki edition is under a moderation notice. That 409 carries `metadata.blockedStandards` — each one with its `level`, `heldClauseCount`, `heldClauseAddresses` and its `fix` ("upgrade" or "remove_or_swap"), plus the replacement edition where there is one — and `metadata.acknowledgmentToken`, a 12-hex fingerprint of that set. THE REFUSAL IS A PROMPT, NOT A WALL: the publish goes through when the body carries `acknowledgeHolds` with that token and those standards. The material under notice is then marked as withheld wherever the revision is read, and the marks lift by themselves when a moderator lifts the hold — nothing is baked in and the content anchor is unaffected. ASK THE USER BEFORE ACKNOWLEDGING; it is their call to issue a specification with a clause marked as withheld, and the acknowledgment is recorded against the published revision with their name on it. A second 409 with a DIFFERENT `acknowledgmentToken` means the notice changed while you were deciding: show the new list and ask again rather than replaying the old token. Clearing the notice first is the alternative (GET /:id/upgrade-status shows the same offer). Draft saves are never affected. A PROJECT WITH NO COMPANY: refused with 409 `code = "project_needs_company"` and `metadata.companies`, the companies the creator of the project may put it under. Repeating the call with `adopt_company_id` gives the project to that company for good (its admins appoint the lead, its seats are charged, it stays if the creator leaves). ASK THE USER WHICH COMPANY, or whether to stop; never choose one for them. Only the creator may choose (403 otherwise). CHOOSING THE COMPANY IS BROWSER-ONLY: an API key or connected app publishing a project with no company is refused with 403 `metadata.code = "DELEGATED_ACCESS_REFUSED"`, `metadata.reason = "project_needs_company"`, and is not shown the companies; `adopt_company_id` from a key is refused, never applied. Tell the user to choose a company for the project in SynC, then try again. A project that already has a company publishes normally.

POST /api/projects/:id/revert

Discard the current draft and revert to the last published revision. Requires a published revision to fall back to — a project whose draft is its ONLY revision cannot be reverted (edit or publish the draft instead).

Required permission: project:draft:delete

POST /api/projects/:id/standards

Add a standard to the project draft (import by reference, or customize it into a project-scoped copy), or move a standard already in the draft to a different project section.

Required permission: project:draft:edit

Optional permission (unlock additional behaviour): standard:customize

Request body (JSON)

Required
Optional

action is one of three: "import" (link an existing standard — THE DEFAULT; "add standard X to the project" always means import), "customize" (create a NEW project-scoped editable copy — ONLY when the user explicitly asks to modify the standard's content for this project; by default it also REPLACES the original in the draft — replaceOriginal: false keeps both), or "recategorize" (MOVE a standard that is ALREADY in the draft from one project section to another — this is the action for "move X into section Y" / "refile X"; it adds nothing and removes nothing, so never archive-and-re-import to reorganize). Any other value is a 400. Every action needs project:draft:edit on this project; "customize" also needs standard:customize there. A customize with no follow-up content edit is always wrong — use import. A standard that is already project-scoped to this project is added with import too, never re-customized. categoryId: OMIT IT ON A PLAIN IMPORT. Left out, the import auto-files the standard into the project section that matches the standard's own category, creating that section only if the project does not already have it — which is the filing the user expects. Naming a categoryId on an import overrides that and drops the standard wherever you pointed; CREATING a category first just to have an id to send manufactures a section nobody asked for and is the wrong move. categoryId IS required for action "customize" (rejected without one) and for "recategorize" (it is the destination section). When you do send it, it must be a project-scoped category of THIS project — a global or company category is rejected — so read the real ones from GET /api/categories?subject_type=project&subject_id=<this project id>. Before adding, check this project's current standards (GET /:id/standards) — if the standard (or a project copy of it) is already there, do not add anything: create the datasheet on the existing one, edit its working copy, or recategorize it if it is only in the wrong section. If the call fails with "a standard with that name already exists", a copy already exists — do NOT retry with a different name (that creates a duplicate); look up the existing one and use it, or ask the user how to proceed. A "customize" whose source standard is under a moderation notice is refused with 409 and `metadata.code = "STANDARD_UNDER_MODERATION_NOTICE"`; the copy would inherit the notice, so the fix named in the error (upgrade to the replacement edition, else remove or swap) is the path. "import" is unaffected. REQUIRES AN OPEN DRAFT: this change lands in the project's current unpublished draft. If the project has no open draft the call is rejected outright — check GET /api/projects/:id/revisions (a revision with is_draft true) and, if there is none, propose POST /api/projects/:id/draft/create first, then make this call. Opening a draft is its own user-approved change; never publish or revert a project to bring a draft into existence.

POST /api/projects/:id/standards/:standardId/archive

Archive (soft-remove) a standard from the project draft.

Required permission: project:draft:edit

Request body (JSON)

Required

REQUIRES AN OPEN DRAFT: this change lands in the project's current unpublished draft. If the project has no open draft the call is rejected outright — check GET /api/projects/:id/revisions (a revision with is_draft true) and, if there is none, propose POST /api/projects/:id/draft/create first, then make this call. Opening a draft is its own user-approved change; never publish or revert a project to bring a draft into existence.

POST /api/projects/:id/standards/:standardId/restore

Restore an archived standard to the project draft.

Required permission: project:draft:edit

Request body (JSON)

Optional

newName RENAMES the standard as part of the restore, and it is the only escape from the one error this call really returns: if an ACTIVE standard in the draft already holds the archived one's name, the restore fails with 409 "a standard with the name X already exists". That is a genuine conflict, not a transient failure — retrying the same call returns the same 409 forever. Do not archive the active standard to clear the way, and do not give up: either restore under a newName that distinguishes the two (say which edition/revision it is, in the words of whoever asked), or tell the user the active standard already covers it and ask which they want. Restoring also brings back any datasheets archived alongside the standard. REQUIRES AN OPEN DRAFT: this change lands in the project's current unpublished draft. If the project has no open draft the call is rejected outright — check GET /api/projects/:id/revisions (a revision with is_draft true) and, if there is none, propose POST /api/projects/:id/draft/create first, then make this call. Opening a draft is its own user-approved change; never publish or revert a project to bring a draft into existence.

DELETE /api/projects/:id

Soft-delete a project. A project that has been shared with anyone returns 409 until the caller acknowledges the consequences — see acknowledgeDistribution. BEFORE deleting a DISTRIBUTED project, consider PATCH /:id/archive instead: archiving closes the job out, keeps existing recipients reading, and is reversible, whereas deleting takes the project away from the people it was sent to. The delete is undoable via POST /:id/restore for 30 days (`restoreExpiresAt` is in the response); after that the owner has no self-serve way back.

Required permission: project:delete

Parameters
NameInTypeDescription
acknowledgeDistributionquerystring"true" proceeds with deleting a project that has been distributed. Without it the request returns 409 listing who received the project — this is deliberate, not a bug, and the 409 body is what the recipient-facing consequences look like. Only send it after a human has been shown that list and said to go ahead. Accepted as a body boolean too (acknowledgeDistribution: true). A project nobody received deletes without it.

POST /api/projects/:id/restore

Bring a soft-deleted project back, inside the 30-day restore window. The exact inverse of DELETE /:id and nothing more — a project archived before it was deleted comes back ARCHIVED. Restoring a distributed project hands access back to its recipients, and is recorded. Returns 409 if the project is not deleted, and 410 once the window has passed. GET /restorable lists what is still recoverable.

Required permission: project:delete

GET /api/projects/restorable

The soft-deleted projects the caller LEADS that are still inside the 30-day restore window, newest first, with the deadline and recipient count for each. Scoped by the standing ProjectOwner grant, not by who pressed delete: a lead who inherits a project inherits everything deleted from it, and a departed deleter sees nothing. Expired deletions are not listed. Restore one with POST /:id/restore.

POST /api/projects/:id/corrections

Issue a correction notice on a published project revision. Does not mutate the revision or its content anchor.

Required permission: project:correction:issue

Request body (JSON)

Required
Optional

severity: minor|major|critical. recommended_action: use_corrected_revision|discard_section|await_correction|consult_other_source|clarification_only|do_not_use|verify_independently|other. 'other' requires recommended_action_note.

POST /api/projects/:id/corrections/:cid/withdraw

Withdraw a correction (state change, retains the audit row — never a delete).

Required permission: project:correction:withdraw

Request body (JSON)

Required

GET /api/projects/:id/corrections

List corrections for a project. Full audit trail (active + withdrawn) by default; ?status=active returns only active banner corrections.

Required permission: project:correction:view

Parameters
NameInTypeDescription
revisionIdqueryintegerScope to one project revision (optional)
statusquerystring"active" returns only active corrections; omit for the full audit trail.

GET /api/projects/:projectId/coordination-checklist

Derived drawing-coordination checklist for a project revision. Defaults to the draft revision if present, otherwise the current published revision. The register is an authorship-side QC work product — recipients of a shared project cannot read it.

Required permission: project:coordination:view

Parameters
NameInTypeDescription
revisionIdqueryintegerSpecific project revision ID (optional)
formatquerystring"csv" returns a text/csv attachment instead of JSON. HTTP CALLERS ONLY — the assistant path does not read this parameter, so sending it here changes nothing about the response and is not a way to alter or shrink a result. An attachment has no meaning as a tool result, so the assistant always receives the JSON rows — never offer the user a CSV download from this action.

POST /api/projects/:projectId/coordination-checklist/checks

Check off (resolve) a narrative checklist row. Upserts on the unique key, stamping the clause fingerprint from the pinned revision. `tagKey` is the normalized drawing-tag text as returned on the row (`rows[].tagKey`); the server re-normalizes whatever it is sent.

Required permission: project:coordination:resolve

Request body (JSON)

Required
Optional

REQUIRES AN OPEN DRAFT: this change lands in the project's current unpublished draft. If the project has no open draft the call is rejected outright — check GET /api/projects/:id/revisions (a revision with is_draft true) and, if there is none, propose POST /api/projects/:id/draft/create first, then make this call. Opening a draft is its own user-approved change; never publish or revert a project to bring a draft into existence.

DELETE /api/projects/:projectId/coordination-checklist/checks/:checkId

Reverse a narrative check-off. Subject to the same published-revision immutability guard.

Required permission: project:coordination:resolve

GET /api/projects/:id/parameters

The project's parameter register for one revision: every parameter name the standards on the revision assert (via [[parameter: …]] in clause prose or in a derived datasheet field's basis), UNION every name the revision has answered. Unanswered names are INCLUDED with a null value — they are the point of the register, and each one prints as "TBD" in the issued document. An answered name whose last asserting standard has left the draft is shown with asserted:false and will be dropped when the revision is published. A published revision serves the description SNAPSHOT frozen at publish, a draft serves the library's current description; descriptionSource says which. On a draft each parameter carries `version`, the opaque token to send back as baseVersion when answering it.

Required permission: project:read

Parameters
NameInTypeDescription
revisionIdqueryintegerSpecific project revision ID (optional). Omitted means the newest revision the caller may read — the open draft for an author, the newest published revision for a share recipient.

PUT /api/projects/:id/parameters/:name

Record this project's answer to one parameter on its DRAFT revision. An upsert: a second answer for the same name on the same revision REPLACES the first — a parameter is answered exactly once per revision, and there is no answer history to choose between. Answering a parameter on a PUBLISHED revision is rejected with a 409; published revisions are immutable, so the change has to go on a draft and be published forward. The value and unit are free text and are never validated, converted or unit-checked: an answer is engineering input, and rejecting it because a parser did not recognise it is the failure this design avoids. Sending value: null clears the answer without deleting the row.

Required permission: project:draft:edit

Parameters
NameInTypeDescription
namepathstringThe parameter name, URL-encoded. Matched case-insensitively with whitespace collapsed, the same way the scanner normalises it — "Site Ambient" and "site ambient" address the same parameter. The name is DERIVED from the asserting standards and cannot be invented here to mean something new: a name no standard on the revision asserts, and which has no answer already recorded, is refused with a 404. Read GET /api/projects/:id/parameters for the names this revision is actually asking for.

Request body (JSON)

Required
Optional

EDIT CONFLICTS: if someone answered this parameter after your read, nothing is written: 409 `code: "EDIT_CONFLICT"` with `metadata.currentVersion` and `metadata.conflicts: [{ name, theirs, mine }]`; re-read and ask the user. The same answer is not a conflict. A missing baseVersion is 400 BASE_VERSION_REQUIRED. The response carries `parameter.version` for the next answer and `draftVersion`. Both value and unit REPLACE what is stored — read GET /api/projects/:id/parameters first if you mean to amend rather than overwrite. An answer already recorded for a name whose last asserting standard has since left the draft (the register shows it with asserted: false) stays writable, so it can be amended or cleared before publish drops it. revisionId is optional and defaults to the open draft, which is the only revision this endpoint can write. REQUIRES AN OPEN DRAFT: this change lands in the project's current unpublished draft. If the project has no open draft the call is rejected outright — check GET /api/projects/:id/revisions (a revision with is_draft true) and, if there is none, propose POST /api/projects/:id/draft/create first, then make this call. Opening a draft is its own user-approved change; never publish or revert a project to bring a draft into existence.

PATCH /api/projects/:id/parameters/:name/description

Write the DESCRIPTION of a parameter that a standard AUTHORED IN THIS PROJECT asserts — what the name means and what unit it expects. The description lands in the project's own library, so it applies wherever that project standard is read. A name the project only inherits from the SynC corpus or a company library is REFUSED (404): its description belongs to that library and is written with PATCH /api/libraries/:id/parameters/:name. Read GET /api/projects/:id/parameters first — a row with describable: true is one this endpoint accepts. The parameter NAME cannot be changed here or anywhere; it is derived from the [[parameter: …]] markers in the asserting standard.

Required permission: project:draft:edit

Parameters
NameInTypeDescription
namepathstringThe parameter name, URL-encoded, normalised the same way the scanner normalises it (case-insensitive, whitespace collapsed).

Request body (JSON)

Required

Not revision-bound: a description is library data. Published revisions keep the snapshot they froze.

GET /api/projects/vertical-types

List the project vertical types as { id, name }. READ THE ID FROM HERE — a vertical_id is never inferred from a number or a name resemblance.

GET /api/projects/status-types

List the project status types as { id, name }. Same rule as vertical types: read the id, do not guess it.

GET /api/projects/checksum/:id

Get the content anchor of a project revision: its SHA-256 content_hash, the short fingerprint (first 12 hex characters), the hash recipe, and the opaque verify_id. A LOOKUP of what was recorded at publication — it does not re-derive anything, so it proves what the anchor IS, not that the content still matches it. For that, use GET /:id/integrity-audit.

Required permission: project:read

Parameters
NameInTypeDescription
revisionIdqueryintegerSpecific project revision (defaults to current)

GET /api/projects/datasheets/:id

Read a project's datasheets both flat and grouped by discipline and specification — the shape the project view renders from. Audience-gated: a recipient does not receive the author-only upgrade signals. Prefer GET /:id/datasheets for a plain list.

Required permission: project:read

Optional permission (unlock additional behaviour): project:revision:view

Parameters
NameInTypeDescription
revisionIdqueryintegerSpecific project revision. A HISTORICAL revision additionally requires project:revision:view — it is refused with 403, never quietly served as the current one.

GET /api/projects/edit-access/:id

Report whether the caller can enter edit mode on a project and whether a draft already exists — the check to run before offering an edit, so the answer is not "start a draft" for someone who cannot. Asking the question already requires the edit permission: a caller who only reads the project gets a 403, which is itself the answer.

Required permission: project:draft:edit

GET /api/projects/:id/deletable

Report whether a project can be deleted, and what the owner must be shown before confirming. Returns `recipientCount` and a `consequences` list for a DISTRIBUTED project (DELETE /:id refuses with 409 until those are acknowledged), whether ARCHIVE is available as the reversible alternative (`archivable`), and `restoreGraceDays` — how long the delete stays undoable via POST /:id/restore. This is the way to find out first; DELETE /:id runs the same check.

Required permission: project:delete

GET /api/projects/:id/draft

Get the project's open DRAFT revision, or nothing if there is none. The read between POST /:id/draft/create and POST /:id/publish — a draft is never visible to recipients. Carries `version` (send back as baseVersion on PUT /api/projects/edit/:id and POST /:id/publish), `editedBy`/`editedAt` (the last write to the draft) and `headerEdits` ({ field: { editedBy, editedAt } }, who last changed each header field).

Required permission: project:draft:view

GET /api/projects/:id/integrity-audit

Deep tamper check of the project's CURRENT PUBLISHED revision. Unlike the checksum lookup, this RE-DERIVES the hash from the stored content, recursively recomputing each child standard and datasheet first (the composite folds child anchors, so a child-level change would otherwise be invisible), and reports whether it still matches the published anchor. Returns a verdict, counts, both fingerprints, and the TYPE and ID of any mismatch — never element content. A project with no published revision answers status "no_published_revision" with ok:null, which is not a failure.

Required permission: project:read

GET /api/projects/:id/revisions/:revisionCode

Get one project revision by its REVISION CODE (e.g. "2.1.0"), not by numeric id — the identifier people cite.

Required permission: project:revision:view

GET /api/projects/:id/revisions/:toCode/diff

What CHANGED between two project revisions: standards added/removed/re-pinned, datasheets added/removed/revised (with the field-level before/after), files added/removed, header field changes, and section headings renamed/re-parented/reordered. :toCode is the NEWER side; the older side comes from ?base=<code> and defaults to :toCode's nearest published predecessor. Any two revisions can be compared, not just adjacent ones. THE OPEN DRAFT IS DIFFABLE by its author — pass the draft's code as :toCode to see what publishing would issue. This is the ONLY way to answer "what changed"; do NOT try to infer it by comparing content hashes, because a draft carries its parent revision's anchor until it is published.

Required permission: project:revision:view

Parameters
NameInTypeDescription
toCodepathstringRevision code of the NEWER side (may be the open draft)
basequerystringRevision code of the older side (optional; defaults to the nearest published predecessor of :toCode)

GET /api/projects/:id/standards/archived

List standards archived out of the project's draft — what POST /:id/standards/:standardId/restore can bring back. They do not appear in the normal standards list.

Required permission: project:read

GET /api/projects/:id/standards/check-name

Check whether a standard name is already taken within the project. Ask before customizing or renaming — a colliding name is refused.

Required permission: project:read

Parameters
NameInTypeDescription
namequerystringCandidate standard name (required)

GET /api/projects/:projectId/clause-citation

Resolve ONE standard clause plus its parent and child clauses, as pinned by the project revision — the citation read behind coordination and comment workflows. Gated on project:read alone: it is a reading affordance, not an AI feature.

Required permission: project:read

Parameters
NameInTypeDescription
standardElementIdqueryintegerThe clause to resolve
revisionIdqueryintegerSpecific project revision (optional)

GET /api/projects/:projectId/comments

List the project comments VISIBLE TO THE CALLER. Visibility is path membership plus current project read access — an empty result means none are addressed to you, not that the project has none.

Required permission: project:read

Parameters
NameInTypeDescription
revisionIdqueryintegerPin to one project revision
anchorTypequerystring'standard_element' | 'datasheet_field'
elementIdqueryintegerFilter to one standard element
datasheetInstanceIdqueryintegerFilter to one datasheet instance
intentquerystringstring — 'position' | 'question' | 'suggestion' | 'scope_assertion'
statequerystringFilter by resolution state
directionquerystringstring — 'internal' | 'terminate_at_counterpart' | 'bubble_up'; inferred from intent when omitted

GET /api/projects/:projectId/comments/counts

Per-element comment counts for the badges: { commentCounts: { [standardElementId]: n } }. Counted through the SAME visibility predicate as the list, so a count never reveals a comment the caller could not read. Datasheet-anchored comments are keyed by their standard element id, matching the standard-element case.

Required permission: project:read

Parameters
NameInTypeDescription
revisionIdqueryintegerPin to one project revision
anchorTypequerystring'standard_element' | 'datasheet_field'
standardIdqueryintegerFilter to one standard
datasheetInstanceIdqueryintegerFilter to one datasheet instance

GET /api/projects/:projectId/comments/unforwarded

The caller's duty-to-bubble-up checklist: bubble_up comments they have received, not yet forwarded onward, and not terminally resolved. Each row is an open obligation.

Required permission: project:read

GET /api/projects/:projectId/comments/rollup

The as-bid rollup: citation-anchored comments visible to the caller, with the provenance trail of who forwarded what and the latest resolution state of each.

Required permission: project:read

Parameters
NameInTypeDescription
audiencequerystringRestrict to one audience, e.g. 'bubble_up'
intentquerystringstring — 'position' | 'question' | 'suggestion' | 'scope_assertion'
statequerystringFilter by resolution state
revisionIdqueryintegerPin to one project revision

GET /api/projects/:projectId/comments/counterparts

List the parties the caller may address a comment to, taken from the live share graph — upstream (who shared to them) and downstream (who they shared to). Read this before composing: a counterpart not on this list is not addressable. Gated on project:comment, because authoring is the only reason to need it.

Required permission: project:comment

POST /api/projects/:projectId/comments

Create a project comment anchored to a standard clause or a datasheet field, addressed to one counterpart. The author silo defaults to the caller's employer company.

Required permission: project:comment

Request body (JSON)

Required
Optional

Set EXACTLY ONE anchor: standardElementId for a standard_element, datasheetElementId (with datasheetInstanceId) for a datasheet_field. A disposition on any intent other than 'position' is a 400, not an ignored field. Counterparts come from GET /:projectId/comments/counterparts; both counterpart fields null makes it a no-chain comment nobody else sees. DIRECTION: an HTTP caller may omit it and have it inferred from intent, because the compose form shows the author their own recipient picker. THE ASSISTANT MAY NOT — it must state direction explicitly, and must name a counterpart on any direction other than internal, or the call is refused before the user is asked to approve it. The approval card is the only place a chat user learns where their comment goes, so an inferred recipient would be one nobody stated and nobody read.

PATCH /api/projects/:projectId/comments/:id

Edit the BODY of your own comment, and only while it is still un-propagated. Once it has been forwarded it is part of somebody else's record and can no longer be changed.

Required permission: project:comment

Request body (JSON)

Required

DELETE /api/projects/:projectId/comments/:id

Delete your own comment, and only while it is still un-propagated.

Required permission: project:comment

POST /api/projects/:projectId/comments/:id/forward

Forward a comment ONE HOP to your immediate upstream counterpart. You must say how you are passing it on — relaying it, endorsing it, or endorsing it with a caveat — because that stance becomes part of the record the next party reads.

Required permission: project:comment:forward

Request body (JSON)

Required
Optional

One hop only — forwarding does not broadcast, and it cannot widen who can see the comment beyond the next party.

POST /api/projects/:projectId/comments/:id/resolve

Set the resolution state of a comment. Ball-in-court is enforced: only the party the comment is currently with may resolve it, and the allowed states depend on the intent.

Required permission: project:comment:resolve

Request body (JSON)

Required
Optional

Legal states by intent — position: accepted, accepted_as_noted, revise_resubmit, rejected, counter; question: answered, closed; suggestion: considered, adopted, declined; scope_assertion: acknowledged, disputed. A state outside its intent is a 400.

POST /api/projects/:projectId/invite

Invite somebody to a project at a given role, by email. An existing account is granted access directly; an unknown email creates a pending invitation. The grant is bound to the email it was issued to. Session only: API keys and connected apps are refused (Tier 1).

Required permission: project:update

Request body (JSON)

Required
Optional

An editor or admin invitation on a project with no company, whose creator now works for one, is asked first. A PROJECT WITH NO COMPANY: refused with 409 `code = "project_needs_company"` and `metadata.companies`, the companies the creator of the project may put it under. Repeating the call with `adopt_company_id` gives the project to that company for good (its admins appoint the lead, its seats are charged, it stays if the creator leaves). ASK THE USER WHICH COMPANY, or whether to stop; never choose one for them. Only the creator may choose (403 otherwise).

PATCH /api/projects/:id/archive

Archive a whole project — the reversible close-out. Existing recipients KEEP reading it; no new share links can be issued and no pending link can be accepted; the project becomes read-only and the retention clock starts. For a finished job that was distributed, this is almost always the right action rather than DELETE /:id, which takes the project away from its recipients. Gated on project:archive (migration 03_159), split out of project:delete so closing a job out does not require the right to destroy one.

Required permission: project:archive

Request body (JSON)

Optional

PATCH /api/projects/:id/unarchive

Return an archived project to the active list, clearing its retention clock.

Required permission: project:archive

POST /api/projects/:id/datasheets/:datasheetInstanceId/archive

Archive a datasheet out of the project's DRAFT. The mirror of the standards archive endpoint, and reversible via the restore route.

Required permission: project:draft:edit

Request body (JSON)

Required

REQUIRES AN OPEN DRAFT: this change lands in the project's current unpublished draft. If the project has no open draft the call is rejected outright — check GET /api/projects/:id/revisions (a revision with is_draft true) and, if there is none, propose POST /api/projects/:id/draft/create first, then make this call. Opening a draft is its own user-approved change; never publish or revert a project to bring a draft into existence.

POST /api/projects/:id/datasheets/:datasheetInstanceId/restore

Restore an archived datasheet to the project's draft.

Required permission: project:draft:edit

DELETE /api/projects/:id/standards/:standardId/archived

Permanently remove an ALREADY-ARCHIVED standard from the project's draft. Only works on archived standards — archive it first. There is no undo, and published revisions that contain it are untouched.

Required permission: project:draft:edit

DELETE /api/projects/:id/datasheets/:datasheetInstanceId/archived

Permanently remove an ALREADY-ARCHIVED datasheet from the project's draft. Same rules as the standards equivalent: archived-only, no undo, published revisions unaffected.

Required permission: project:draft:edit